أَغْنِنَا بِحَلَالِكَ عَنْ حَرَامِكَ

Journal des versions — chaque sortie de keel, de la plus récente à la plus ancienne

Chaque version de keel, de la plus récente à la plus ancienne, exactement comme publiée sur GitHub Releases. La construction importe les notes ; personne ne met cette page à jour à la main.

Les notes de version sont publiées en anglais ; le texte ci-dessous fait foi.

v0.21.0

Voir cette version sur GitHub ↗

Built from 9b1db8755cf7. Version binds to this hash: keel --version reports keel 0.21.0+9b1db8755cf7 [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.21.0-py3-none-any.whl
keel versions

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

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

Configure

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

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

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

Features

feat(doctor): ledger.drift and ledger.venue_drift, fed by a per-cycle venue-holdings record (#799, #798) (#898)

P3 of the DCA sleeve sell-side build plan (docs/superpowers/plans/2026-09-28-dca-sleeve-sell-side-build.md, Tasks 3.1 and 3.2). It adds two doctor findings that compare the positions ledger with the orders log (ledger.drift) and with the venue’s own holding (ledger.venue_drift). The venue holding comes from a new per-cycle record. It also clears four follow-ups carried over from the P1 and P2 reviews.

Closes #799 Refs #798

What changes

Task 3.1: ledger.drift (#799 proposal 3, plan R2)

  • New module keel/execution/sleeve.py:
    • ledger_qty sums open tranche qty.
    • orders_qty(repo, product, mode) nets filled BUY minus SELL in the requested mode. It is deliberately not executor._held_position, which is live-only by design (#881).
  • doctor.ledger_drift_findings warns when the two disagree by more than one cached base increment. An unknown increment means exact equality. A product that only the orders log knows (the #799 shape) reads as a ledger of zero.
  • Wired into gather_findings from repo reads only:
    • Products: open tranches, plus products with a filled order in this profile’s own mode.
    • Mode: the profile’s own mode (paper reads paper fills).
    • Tolerance: the cached base_increment: record. Doctor never calls a broker.

Task 3.2: ledger.venue_drift (#798, plan R3)

  • reconcile.record_venue_holdings(broker, repo, now_ts) makes one get_balances() call and writes venue_holding:<product> = {"total": str, "observed_at": int} for each product with an open tranche.

    • It reads Balance.total, never available (#667).
    • It places and cancels nothing.
    • It makes no call when nothing is held.
  • run_once calls it last, after exits, entries and _manage_stops (moved there in review, #899, so a same-cycle buy is recorded), on live cycles only (paper_trader is None). The call is wrapped so that a failed diagnostic write never costs a cycle.

  • doctor.venue_drift_findings warns in two cases:

    • the ledger holds more than the recorded venue total (the WARN prints the observation date);
    • a held product has no observation.

    The venue holding more than the ledger is not drift. A paper profile passes an empty ledger and reads OK.

Carried-over items

  • (a) A degenerate venue increment can no longer raise out of a bracket leg after a filled entry. Traced:

    • Instrument accepts Decimal('Infinity') (it is > 0).
    • The fresh get_instrument answer skipped _coerce_increment entirely.
    • The cached path let Infinity through, and NaN > 0 raises.
    • quantize_down then raises InvalidOperation, which is an ArithmeticError, not a ValueError.

    Fixed at both levels:

    • _coerce_increment requires a finite increment, and the fresh answer goes through it.
    • The spec-build catches in place_bracket and _roll_stop now include ArithmeticError. A finite increment far finer than the 28-digit context (1E-40) still raises DivisionImpossible, so fixing the cause alone was not enough. In _roll_stop the old bracket is already cancelled, so an escaping exception would have skipped the CRITICAL.

    New tests show that a filled entry keeps its tranche, both at the executor level and through run_once.

  • (b) _unlinked_pending_sells reads executor.RESTING_STATUSES, so an unlinked partially_filled SELL also leads to “reconcile first”.

  • (c) position.unprotected’s fix line now gives advice per product. When only some unprotected products have an unlinked resting SELL, the others keep “place one at the venue or close the tranche”. The two parts are joined with |.

  • Review round 1 also reworded the two new fix lines, which no longer name the unshipped keel positions close; a new test in test_doctor_fix_lines.py scans backtick-quoted invocations.

  • (d) Renamed test_unmanaged_matches_on_product_not_on_rule_id to test_unmanaged_matches_on_product_and_kind_not_on_rule_id.

…truncated — full description in #898.

Other changes

docs(plan): DCA sleeve sell-side build — prerequisites, reverse_dca, preview trims and exit monitor (#878)

This PR adds the implementation plan for the DCA sleeve’s sell side: docs/superpowers/plans/2026-09-28-dca-sleeve-sell-side-build.md. It builds the design merged in #859 (issue #857) in the order the operator set on 2026-09-28:

  1. Prerequisites: #799, #811 and #798.
  2. reverse_dca.
  3. Trims (keel dca trim --preview, and profit_take in preview) and the exit monitor (sleeve_exit), both preview-only, before any live placement.

Out of scope: steering, band rebalancing as a rule, and rotation or tax-loss harvesting. The spec recommends not building them. This PR is a document only; nothing is built.

PR sequence (merge order)
#PRClosesSizeSchema
P1fix(execution): a bracket leg that throws after a filled entry downgrades to open-unbracketedrefs #799Snone
P2feat(doctor): position.unmanaged and position.unprotected#811Mnone
P3feat(doctor): ledger.drift and ledger.venue_drift#799Mnone
P4feat(positions): keel positions close <id>#798Mnone
P5feat(strategy): sell-side contracts: Holding, Reduction, Action.REDUCE, reduce_signal, book_exit(is_dca=None)—Mnone
P6feat(data): the sell_proposals table and keel dca proposals list/show—Mv22
P7feat(execution): executor.reduce in preview—Mnone
P8feat(agent): _handle_reductions—Mnone
P9feat(rules): reverse_dca, preview-only—Mnone
P10feat(dca): keel dca distribute --preview and its doctor findings—Snone
P11feat(sim): the reverse path—Mnone
P12feat(promotion): the sleeve_sell gate, proposal replay and proposals review—Mnone
P13feat(dca): keel dca trim --preview --view lots and --view bands—Mnone
P14feat(rules): profit_take in preview, and --view gain—Mnone
P15feat(execution): the sleeve exit monitor—Mnone
P16feat(rules): sleeve_exit, preview-only#857Snone
P17feat(autonomy): keel autonomy on --sells—Mv23
P18feat(dca): --confirm <proposal-id> behind the sells window and a typed yes—Mnone

P17 and P18 are the gated placement path, and they are off by default. They merge only when the operator says so (R28). No PR is size L.

Safety invariants every PR states and tests
  • S1: no new sell reaches the venue unless keel autonomy on --sells was armed at a TTY. The live autonomy on flag alone never releases one. An AST inventory of every function that reaches the venue’s order endpoint enforces this, from P1 onward.
  • S2: everything is preview-only. Every sleeve_sell kind declares Execution = Literal["preview"], so execution: auto does not exist.
  • S3: the rails apply to sells as spec §3.4 says. Rails 1, 2 (as a slicing obligation), 9, 10, 12, 18, 19 and 21 apply to a sell. Rails 13, 17, 20 and 22 are buy-scoped in guards.py, and P7 pins both halves.
  • S4: the browser and the MCP server can never increase capability. This is enforced by a name scan plus the existing effect scan.

The research freeze holds. reverse_dca’s sim and replay tests are fidelity checks against hand computations on synthetic candles. The trim preview only reports numbers. Nothing is added to the trials ledger.

Every ruling is recorded as what — why — cost if wrong (R1–R32). The plan also lists where the spec and the issues contradict the code today (C1–C19), and the open questions with the default this plan builds (OQ1–OQ12).

docs(plan): sell-side build plan follow-up — #887, doctor OK findings, P18 exit codes (#888)

Follow-up to #878 (docs/superpowers/plans/2026-09-28-dca-sleeve-sell-side-build.md). Plan text only; no code changes.

What changes
  • #887 (P18, Task 18.1). executor.reduce’s success path now decides from repo.get_open_positions after book_exit, instead of from the pre-sale protecting_remainder arithmetic. This mirrors agent._handle_exits’s tail (keel/agent.py:950-987):
    • A true full close clears position_rule:, open_stop:, open_target: and unbracketed: together. Before, only unbracketed: was cleared, so a stale open_stop made rail 9 (no_stop_widening, keel/execution/guards.py:657-664) veto the next entry.
    • A full-close SELL that the venue fills short (#446) rewrites the retry record from the ledger and logs CRITICAL. It no longer clears the record.
    • There are two new named tests: test_887_a_full_close_clears_position_rule_and_open_levels_too and test_887_a_short_filled_full_close_rewrites_the_retry_record_from_the_ledger. Step 2 item 5 records how each one fails first.
  • P10 / P15 doctor findings. When there is nothing to report, sleeve_rule_findings returns OK for both of its names and exit_watch_findings({}) returns one OK sleeve.exit_watch. This matches P3’s ledger.drift sentinel, so the exact-set test at tests/commands/test_doctor.py:603 keeps every name. Each case has a new test.
  • Task 18.1 Step 2 item 1. Only test B (..._partial_confirmed_leg_re_brackets_...) uses _confirm_recording_cancel_state. Test C uses a bare lambda.
  • Post-confirm failure comment. A guard veto and the spread gate both run inside _run_order before confirm_fn is called, and the spread gate only applies to BUYs. After a yes, the only way the SELL can fail is a PlaceResult(success=False) from the broker. The failed reason no longer claims a bracket was cancelled for a tranche that never had one.
  • R35: P18 exit codes (new). --confirm exits 0 only when the decision is placed. declined and failed both exit 1 through click.ClickException(f"{decision}: {reason}"), which is the convention across keel/commands (_require_interactive_confirmation, orders cancel). Codes 4 and 5 are reserved for the day-stamping wrapper contracts in agent.py. --confirm requires a TTY, so no wrapper ever reads its status, and the message is what tells a decline from a failure. Task 18.2 gains two exit-code tests.

Closes #887

fix(execution): a bracket leg that throws after a filled entry downgrades to open-unbracketed (#799) (#889)

P1 of the DCA sleeve sell-side build (docs/superpowers/plans/2026-09-28-dca-sleeve-sell-side-build.md, Tasks 1.1 and 1.2).

What

#799 proposal 2: a filled entry is never lost to a later bracket failure (plan R5). place_bracket runs after the entry has filled. Before this PR, an exception from the bracket leg unwound through execute and run_once past _open_tranche. That is how the August PAXG entry was left with no ledger row, no exit and no stop. place_bracket now returns None for every bracket-leg failure, and the stage it failed at decides what happens next:

StageRetry record unbracketed:orders rowLog
Before the bracket row exists (preview raised; spec build already handled)written, same as a vetononeWARNING executor.bracket_not_placed
After it exists (place_order raised, TradeScopeDenied included)not written, and a leftover one is clearedstays pendingCRITICAL executor.bracket_state_unknown, naming the row

In the second stage the venue may already be holding the bracket, so a retry could commit the same base twice. Any later exit on the product fails closed on that pending row (_clear_resting_bracket) until someone reconciles it by hand.

The sell-side invariants module, tests/execution/test_sell_side_invariants.py (S1, S4). It pins, by AST scan:

  • PLACEMENT_CALLERS, the only function that calls broker.place_order;
  • RUN_ORDER_CALLERS, the four functions that call _run_order;
  • SECOND_LEVEL_CALLERS, every function under keel that calls executor.execute/place_bracket/scale_out/roll_stop_to/roll_to_break_even/trail_stop_atr;
  • WEB_FORBIDDEN_NAMES, together with a check that executor.reduce does not appear in keel/web or keel/mcp.

This PR also closes a gap the plan left in that module. Code can reach those names without writing executor.<name>: through from keel.execution.executor import scale_out, or through a call on an alias of the module such as executor_mod. Both are now pinned shut, and a synthetic test shows the scan catches each one.

Safety invariants
  • S1: no new sell reaches the venue without keel autonomy on --sells at a TTY. P1 adds no sell path and no new caller of _run_order, place_order or any executor.* order entry point. The invariants module added here pins the current inventory. executor.reduce does not exist.
  • S2: v1 execution is preview-only. Unaffected: no sleeve_sell rule class exists yet.
  • S3: rails on sells. No rail is changed. The bracket still goes through guards.check unchanged inside _run_order.
  • S4: the browser never increases capability. Pinned by test_the_browser_and_mcp_name_no_sell_side_operation. keel/web and keel/mcp are untouched.
Tests
  • tests/execution/test_bracket_downgrade.py: the preview-stage throw and the place-stage throw (TimeoutError, TradeScopeDenied), plus a control case where the bracket places normally. Each fake counts how many SELL previews and places reach the venue, so neither test passes without doing anything.
  • tests/test_agent.py::test_799_a_throwing_bracket_preview_still_records_the_tranche: the #799 acceptance test. The tranche is in the ledger with initial_stop=95.00 and a retry record. Without the fix it fails with InvalidOperation out of run_once.
  • tests/execution/test_reconcile.py::test_a_retry_whose_placement_state_is_unknown_is_not_retried_again: the sweep does not place a second bracket after a place-stage throw. Without the clear it places twice.
  • Mutation checks, each confirmed to have changed the source:
    • dropping the clear kills the reconcile test;
    • forcing the preview-stage branch kills 3 tests.
  • Full gate: pytest (7088 passed, 3 skipped), ruff check, ruff format --check, bare mypy.
Deviations from the plan
  • The plan’s agent test used _config() unchanged. Rail 2 vetoes that entry at the default 1% risk, so the bracket leg was never reached. The test now uses risk_pct=0.0001 and asserts the SELL preview actually ran.
  • The place-stage branch clears an unbracketed: record left from an earlier attempt, as well as not writing a new one. The plan text only said it writes none. Without the clear, the reconcile sweep’s retry would re-place the bracket on the next cycle, which is exactly what R5 says must not happen.
  • The import and alias check was added to the invariants module.

Refs #799. This PR does not close it: proposal 3, the doctor check, lands in P3 as ledger.drift (plan R1).

feat(doctor): position.unmanaged and position.unprotected (#811) (#896)

Summary

P2 of the DCA sleeve sell-side build plan (docs/superpowers/plans/2026-09-28-dca-sleeve-sell-side-build.md, P2 as revised in #888).

Nothing in keel doctor answered “is anything still watching this held tranche?”. The only channel that reported PAXG tranche 3 was the reconcile sweep, and the sweep is keyed on the unbracketed: retry record. When that record was cleared, the channel went quiet and the exposure stayed. These two findings are computed from the positions ledger, so clearing a record cannot silence them.

  • position.unmanaged (WARN): an open tranche with no rule at the profile’s managed status (live, or paper on a paper profile, mirroring agent.py’s rule_status) whose product AND kind match the tranche’s (product_id, rule_name). It does not use rule_id, which is NULL before #803. Product alone is not enough (#897): _handle_exits finds the owner by r.name == rule_name, so a live dca rule cannot exit a turtle tranche on the same product. The detail names the product, the tranche id, and the current status of the newest rule of that (product, kind), or no rule.
  • position.unprotected (WARN): an open tranche with initial_stop > 0, no resting bracket (reconcile._has_resting_bracket), and no retry record that still holds an intent. On a paper profile it is skipped and reported OK: a paper fill never gets an exchange bracket, so the check would fire on every stopped paper tranche.
  • Fix line under R5 (#799). If a pending live SELL exists on the product and no open tranche names it as its bracket, position.unprotected’s fix line says “reconcile the pending order first” and names the order ids, instead of “place one at the venue”. Such an order’s placement state is unknown, and a second bracket over it would double-commit the base.
  • Both are WARN, never FAIL. When they warn, products is populated in --json (#642).
  • Both are wired into gather_findings right after ledger.unbooked_exit. The call stays read-only: the change counter is pinned by a test that has rows on the new reads’ path.
  • Both names are added to the exact-set assertion in test_gather_findings_covers_every_check_over_a_seeded_db.
Safety invariants
  • S1: no new sell reaches the venue without keel autonomy on --sells at a TTY. This PR adds no caller of _run_order, broker.place_order or any executor.* placement function. tests/execution/test_sell_side_invariants.py passes with its pins unchanged.
  • S2: v1 execution is preview-only. This PR adds no rule class.
  • S3: rails on sells are unchanged. This PR changes no rail.
  • S4: the browser never increases capability. No keel/web/ or keel/mcp/ module is touched. The MCP doctor tool inherits two read-only findings through gather_findings.

A doctor finding places nothing.

Acceptance (#811)
  • A demoted rule with an open tranche produces position.unmanaged WARN naming the product, the tranche id and the rule’s current status (paper).
  • A stopped tranche with no resting bracket and no retry record produces position.unprotected WARN. A pending bracket or a live retry record clears it. A retry record cleared to None does not.
  • A DCA tranche (no initial_stop) produces neither.
  • Both appear in --json with products == ["PAXG-USD"].
  • The ~/keel/keel-live.db bullet is replaced by a fixture of tranche 3’s exact row (this plan may not touch ~/keel). The operator runs keel doctor on the deployment after the release.
Test plan
  • tests/commands/test_doctor_position_watch.py (new): the pure function, including the paper-profile skip, the choice of the newest rule’s status, and a check that the resting predicate is consulted for each stopped tranche.
  • tests/commands/test_doctor.py: wiring tests for the live-shape WARN pair plus render_json products, read-only with rows present, retry record live vs. cleared, bracket pending vs. cancelled, and paper profile OK with a live-profile control.
  • Mutation checks, each proven applied and each killed: 7 mutants in the first commit; product-only membership (#897); the literal “live” in the headline; and 4 mutants on the pending-SELL read (the link filter, the side filter, the status filter, and the wiring).
  • uv run pytest -q (7128 passed), ruff check, ruff format --check, bare mypy.

Closes #811

feat(positions): keel positions close — an operator-declared exit the rails can read (#798) (#901)

P4 of the sell-side build plan (docs/superpowers/plans/2026-09-28-dca-sleeve-sell-side-build.md, Tasks 4.0–4.2).

What ships
  • Task 4.0 (R33): guards._open_exposure_by_asset nets per product. A product whose filled BUY qty minus SELL qty is <= 0 now adds nothing to its asset bucket, whatever its net notional is. Before this, a full close at a loss left a phantom residual. PAXG tranche 3 (0.0132 bought at 4673.23, closed at 4400) kept $3.61 of “exposure” with zero units held. A product that is still net-long nets by notional as before, pinned by test_a_partial_close_still_nets_by_notional. There is one more change, now pinned: a product fully closed at a gain can no longer drive its bucket negative and shrink a sibling product’s figure. That is the closed direction for a cap.
  • Task 4.1 (R4): close_declared_position is in the new module keel/commands/positions_close.py. It writes one orders row with mode=live, SELL, order_type=out_of_band, status=filled and confirmation=operator_declared. The row’s qty and filled_quantity both equal the tranche’s ledger qty. It also carries the operator’s price and fee and the tranche’s rule_id. The service then books that one tranche through record_closed_trade, with is_dca taken from the tranche’s own rule_name, and closes it. When it closes the product’s last tranche, it also retires position_rule:, open_stop:, open_target: and unbracketed:. It refuses the following before anything is written:
    • an unknown or already-closed id;
    • a price that is <= 0 or not finite;
    • a fee that is < 0 or not finite;
    • a paper profile.
  • Task 4.2 (R30): keel positions close <id> --price P [--fee F] is a new positions group with this one verb. positions_close_gate runs the refusals first, then the typed yes gate, then the write. It has one new capability row.
  • Doctor. The fix lines on ledger.venue_drift and position.unmanaged now name keel positions close <id> --price P. The #798-pending allowlist in test_doctor_fix_lines is gone. ledger.unbooked_exit no longer reads an out_of_band SELL as unbooked. Without that change, declaring a close on a newer tranche would WARN for as long as an older tranche is held.

Closes #798. Together with P3 (#898: balance reconciliation and ledger.venue_drift) and Task 4.0, all three of #798’s proposals are delivered.

Safety invariants
  • S1: nothing reaches the venue. The command builds no broker at all. The CLI tests monkeypatch every _build_broker seam to raise and count calls, and they assert zero calls. test_sell_side_invariants.py’s PLACEMENT_CALLERS, RUN_ORDER_CALLERS and SECOND_LEVEL_CALLERS pins are unchanged. P4 adds no placement path.
  • S2: v1 execution is preview-only. Untouched. P4 adds no rule class.
  • S3: rails on sells. Untouched. No intent goes through guards.check here, because nothing is placed. Task 4.0 changes only what rails 4/5/6 measure for a fully closed product.
  • S4: the browser never increases capability.
    • close_declared_position joins WEB_FORBIDDEN_NAMES.
    • The gate function calls close_declared_position, so the web effect scan in tests/web/test_server.py derives it as forbidden automatically.
    • close joins the MCP write vocabulary (tests/mcp/test_readonly.py, docs/mcp-server.md).
    • I planted a call to close_declared_position in keel/web/api.py as a check. Both the S4 name scan and the effect scans went red.
Capability row

keel.commands.positions_close.positions_close_gate (cli, tty). Declaring a close shrinks the exposure that rails 4/5/6 measure, and that is headroom for new entries. The prose capability counts move from nine to ten (capabilities.py, keel/web/*, two JS comments; node --check passes on both).

Rulings made here (beyond the plan)

…truncated — full description in #901.

fix(execution): book tranches and brackets at the venue-filled size (#900) (#905)

Refs #900. This PR fixes the problem going forward only. It does not backfill the four existing live rows (orders 1, 2, 4 and 8 and their tranches), because the operator is checking the venue balance first.

Root cause

Nothing ever recorded the venue’s filled_size for a live BUY:

  • _upgrade_to_observed_economics read the order status exactly once, in the same instant as place_order.
  • A market IOC that is read back that quickly has not settled at Coinbase yet. The answer is PENDING/OPEN with filled_size 0.
  • _record_observed_fill_quantity dropped the zero without logging anything.
  • The row had already been written as filled (_initial_status), and reconcile only polls RESTING_STATUSES. So nothing ever read the order again.

As a result filled_quantity stayed NULL. The tranche (agent._open_tranche) and the bracket (execute) were both sized from intent.qty = quote / expected price, but the venue delivered (quote − fee) / price.

The four live rows showing actual_fill equal to the expected price, with previewed fees, fits a status read that returned nothing usable.

What changes
  1. Venue filled size is observed on both sides. The executor now re-reads the order status up to 3 more times (FILL_OBSERVATION_PAUSES = 0.5/1/2s, 3.5s at most, once per market order) until the venue reports a terminal state. TERMINAL_ORDER_STATUSES is pinned equal to reconcile’s _FILLED ∪ _DEAD. filled_quantity is recorded only from that terminal answer. If no terminal answer comes, it stays NULL, never guessed, and executor.fill_quantity_unobserved logs a WARNING.
  2. Tranche booking. agent._open_tranche books filled_quantity when the venue gave one. Otherwise it books the ordered qty and logs agent.tranche_qty_unobserved at WARNING. The warning fires for live rows only.
  3. Protective bracket. It is sized from the same delivered quantity (executor.delivered_qty), so its SELL never exceeds what was received. That was the #446 oversized-bracket condition, and it happened on every fee-in-quote entry. The partial-fill warning now keys off the terminal status (CANCELLED/EXPIRED with fills) instead of filled < qty. A quote-sized BUY always has filled < qty, so the old check fired on every entry.
  4. Consistency. These now all read filled_quantity or qty: sleeve.orders_qty, executor._held_position, and agent._held_position. R33 (guards._open_exposure_by_asset) already did. A new pin shows that closing every tranche with keel positions close nets the product to exactly zero on every reader, and that R-f admits the close.
  5. Paper and sim. Neither is affected. PaperTrader, strategy/backtest and sim/account charge the fee on top of the notional, so their qty is the quantity delivered. sim/accumulation_policy (#831 research) already models fee-in-quote and never writes orders. The new tranche WARNING does not fire for mode='paper'.
  6. Spend and exposure counts are unchanged. guards._order_notional adds the fee to a BUY that has an observed filled_quantity. A $50 quote BUY with a $0.45 fee therefore still counts $50 toward rails 3 and 14 (and 4/5/6), which is what the ordered qty × price gave before. The fee is not added on the qty fallback, where the quote already contains it, nor on a SELL.
  7. Price and fee come only from the terminal read. actual_fill and fee are upgraded only from the same terminal observation as filled_quantity. Otherwise the estimates stand.
  8. doctor ledger.venue_drift. If a product’s gap is above the tolerance but no larger than tolerance + Σ(entry_fee / entry_fill) over its open tranches, its clause names #900 and its fix line no longer sends the operator to keel positions close. For every drift, the explanation now lists fee-in-quote overstatement among the possible causes. The tolerance (one increment) and the WARN status are unchanged.

Review round 1 (#906, #907):

  • fill.partial no longer judges a filled market BUY by filled < qty, because that qty is a quote estimate.
  • The #900 fee band counts only tranches backed by a live BUY with NULL filled_quantity and an equal qty.
  • The fee figure in the clause is quantized up to the base increment.
  • fill_quantity_unobserved uses exit wording on a SELL.
Rulings

…truncated — full description in #905.

feat(strategy): sell-side contracts — Holding, Reduction, Action.REDUCE, reduce_signal, book_exit(is_dca=None) (#857) (#908)

Refs #857. P5 of docs/superpowers/plans/2026-09-28-dca-sleeve-sell-side-build.md (Tasks 5.1–5.4). It adds contracts only: no placement path, no rule kind, no schema change.

What lands
  • keel/strategy/reduction.py (new, pure; R6–R8): Lot, Holding, SellCosts, Reduction.
    • Holding.from_rows keeps the ledger’s FIFO order and filters nothing by rule_name.
    • Cost basis includes the entry fee, prorated by qty / (qty + realized_qty) (spec §3.3, Q4).
    • SellCosts refuses fee plus slippage outside [0, 1).
    • Reduction refuses a non-positive qty or price, and an empty reason.
  • Action.REDUCE and Rule.reduce_signal(holding, candles_by_tf, costs) -> None by default (R6). A test checks every kind in RULE_REGISTRY returns None over a real holding.
  • promotion.SLEEVE_SELL and promotion.RECOGNISED_CLASSES (R10). The conformance test now reads RECOGNISED_CLASSES. SLEEVE_SELL is deliberately not in _CLASS_FLOORS.
  • streak.book_exit(is_dca=None) derives each leg’s flag from its own tranche’s rule_name (R9, spec §3.2, Q10, #860). A bool keeps today’s behaviour exactly, and the existing callers are untouched. Tested on the PAXG shape: the turtle leg counts toward rail 16 and the DCA leg does not.
  • sleeve.holding_of(repo, product_id, mark=None). Its qty is pinned equal to ledger_qty over the same rows.
    • Both Holding and holding_of document #900: tranches booked before #905 still carry the ordered qty until a backfill runs, so the ledger can overstate the venue by about a fee.
    • No venue clamp is added. _clamped_sell_qty and ledger.venue_drift already cover that gap.
A REDUCE cannot run through any existing path

executor._build_intent treated every non-ENTER action as an EXIT. So a REDUCE (or NONE) handed to executor.execute would have sold the whole held position. The new red test showed it placing a SELL.

  • _build_intent now raises ActionNotExecutable for anything outside EXECUTABLE_ACTIONS = {ENTER, EXIT}, before it reads anything.
  • execute and agent._paper_enter both catch it and return not placed.
  • The tests, parametrised over set(Action) - {ENTER, EXIT}, assert:
    • no get_balances, cancel, preview or place call, and no new orders row on the live path;
    • on the paper path, zero calls to guards.check and PaperTrader.on_signal (spies count them), and no paper row.
Safety invariants
  • S1: no new caller of _run_order or broker.place_order, and no new second-level caller. tests/execution/test_sell_side_invariants.py is unchanged and green. executor.reduce does not exist yet. REDUCE is refused by execute and the paper path (above).
  • S2: no sleeve_sell rule kind exists yet. reduce_signal returns None on every registered rule, so nothing here runs on the live cycle.
  • S3: unchanged. No rail is touched.
  • S4: unchanged. No keel/web/ or keel/mcp/ module is touched.
Verification
  • Full uv run pytest -q: 7291 passed, 3 skipped.
  • uv run mypy (bare) is clean, and so are ruff check keel tests and ruff format --check.
    • Bare ruff check reports one pre-existing E501 in docs/experiments/2026-09-02-equities-cost-fidelity.py, which is on main and untouched here.
  • Every new test was watched failing for its stated reason first.
  • Mutation run: 16 mutants across reduction.py, promotion.py, executor.py, agent.py, streak.py and sleeve.py. For each one, the source was asserted to have changed before running and restored from a saved copy afterwards. All 16 were killed.
Deviations from the plan
  1. Added the ENTER/EXIT-only chokepoint in executor._build_intent, with its catches in execute and _paper_enter. The plan does not have it, but without it Action.REDUCE would be executable, as a full exit, by an existing path.
  2. test_every_registered_rule_proposes_no_reduction_by_default iterates the whole RULE_REGISTRY (six kinds), not the plan’s four hard-coded kinds.
  3. Extra tests beyond the plan: from_rows order and decoding, unrealised, the empty Reduction.reason, the SellCosts boundary at exactly 1, a partial is_dca=None sale that stops inside the DCA tranche, and an empty holding_of.

feat(data): sell_proposals, schema v22, and keel dca proposals list/show (#857) (#909)

Refs #857

P6 of docs/superpowers/plans/2026-09-28-dca-sleeve-sell-side-build.md (Tasks 6.1–6.3).

[!IMPORTANT] Schema v22. This PR bumps SCHEMA_VERSION 21 → 22. It adds one table, sell_proposals, and one index, idx_sell_proposals_product_ts. The migration is additive only: no existing table, column or row is touched, and nothing is backfilled. At deploy, run keel --db <db> migrate (prints schema 21 -> 22). Until that runs, the read-only keel dca proposals verbs refuse the database with the stale-schema message. They never migrate it themselves.

What
  • Task 6.1: v22 migration (keel/data/db.py). The sell_proposals DDL, with the plan’s 21 columns and the (product_id, ts) index that R14’s per-day read uses. _migrate_v22_sell_proposals is a documented no-op in the v17/v19 pattern: IF NOT EXISTS creates the table before the version loop runs. Also in this task:
    • _MIGRATIONS[22] and SCHEMA_VERSION = 22.
    • Schema-version pins:
      • the eight == db.SCHEMA_VERSION == 21 pins in test_migrations.py relax to db.SCHEMA_VERSION;
      • test_trade_outcomes.py’s pin relaxes and is renamed test_schema_is_at_the_current_version;
      • test_db.py’s deliberate tripwire is bumped to test_schema_version_is_22.
    • New asserts use db.SCHEMA_VERSION.
    • One migration-safety test builds a populated v21 file: orders, an order update, a tranche, a journal entry, and a 3-event audit chain, all in WAL mode. It takes the file to v22 through the real keel --db <file> migrate CLI. It then asserts:
      • every pre-existing table is unchanged, compared row by row;
      • sell_proposals is empty;
      • the audit chain still verifies with the same event count.
  • Task 6.2: repository and audit events (keel/data/repository.py, keel/data/audit.py).
    • insert_sell_proposal and update_sell_proposal each write the row and its audit event in one write_transaction. The events are sell_proposal_recorded and sell_proposal_updated, in the chain’s closed <store>_<verb> vocabulary. They are added to EVENT_STORES under the store sell_proposals.
    • get_sell_proposal and get_sell_proposals return rows newest first. They filter by product, rule and since_ts, and take a limit. Money comes back as Decimal and trigger/rails as dict. A pre-v22 database reads as [] or None, using the table_present idiom.
  • Task 6.3: keel dca proposals list and keel dca proposals show <id> (keel/commands/dca.py, and the new keel/commands/sleeve_report.py).
    • Both verbs are read-only. They always open the database through _common._open_repo_ro, at a TTY and off one.
    • sleeve_report.render_proposal_line and render_proposal are pure renderers that P8 and P18 will reuse.
    • A NULL figure prints as unrecorded, never as $0.00, and the fee is always printed with its source.
Safety invariants
  • S1 (unchanged): no new sell reaches the venue without keel autonomy on --sells at a TTY. This PR adds no placement path. PLACEMENT_CALLERS, RUN_ORDER_CALLERS and SECOND_LEVEL_CALLERS in tests/execution/test_sell_side_invariants.py are unchanged and green.
  • S2 (unchanged): v1 execution is preview-only. No rule class, execution value or placement branch is added.
  • S3 (unchanged): the rails on sells. This PR has no REDUCE intent and does not touch guards.check.
  • S4: the browser never gains a capability. insert_sell_proposal and update_sell_proposal join WEB_FORBIDDEN_NAMES. The web/MCP scan is now a helper, _web_offenders, and a new test, test_the_web_scan_catches_the_sell_proposal_writers, proves it catches both writers, whether named as an attribute or as a bare name.
Rulings made while building (what — why — cost if wrong)
  • update_sell_proposal’s event takes its timestamp from now. The table has no updated_at, and an update event is a statement that something changed at that moment. Cost if wrong: an event’s ts would differ from a caller-chosen time by seconds.
  • The chain test reads events through audit.read_events, not repo.audit_chain().events. The plan’s test sketch used the latter, but ChainState exposes no event list. The plan said to fall back to the reader test_audit_chain.py uses, and the test does that.
Deviations from the plan

…truncated — full description in #909.

feat(execution): executor.reduce in preview — rails, venue preview, sleeve caps, rail-2 slicing (#857) (#910)

Refs #857. Plan P7, Tasks 7.1–7.2 (docs/superpowers/plans/2026-09-28-dca-sleeve-sell-side-build.md).

What
  • keel/execution/sleeve.py (Task 7.1, R11–R15, spec §3.4/§3.6): sell_costs, slice_qty, proposed_today, last_proposal_ts, sleeve_refusal, record_proposal (the one proposal writer), unverified_fill_orders, and the constants FALLBACK_FEE_SOURCE, VENUE_FEE_SOURCE, SAME_DAY_DCA/MIN_HOLD/COOLDOWN, DEFAULT_MIN_HOLD_DAYS, MIN_HOLD_EXEMPT_KINDS, ARBITRATION_ORDER. The module docstring carries one paragraph per ruling.
  • keel/execution/executor.py (Task 7.2, R17): ReduceResult and reduce, preview only. It caps the sale at the ledger’s holding, clamps it to the venue’s holding (_clamped_sell_qty), slices it for rail 2, runs guards.check unmodified, builds the spec, calls preview_order, and records one sell_proposals row. It never cancels, never writes an orders row and never places. A preview failure is recorded on the fallback fee; it is never raised.
Safety invariants
  • S1: reduce calls none of _run_order, place_order, insert_order, update_order, _clear_resting_bracket, _cancel_at_exchange, cancel_order or place_bracket.
    • PLACEMENT_CALLERS, RUN_ORDER_CALLERS and SECOND_LEVEL_CALLERS are unchanged.
    • A new AST pin, test_reduce_reaches_no_placement_cancel_or_order_writer, is paired with a false-capable companion test.
    • Spy tests cover all six decision outcomes (test_reduce_never_places_cancels_or_writes_an_order_for_any_decision): venue preview, a preview that raised, a rail veto, below one increment, nothing held, and offline. Each outcome has a seeded resting bracket and spies on the broker, the repository’s order writers and the executor’s cancel and placement helpers.
  • S2: preview is the only execution value. reduce has no placement branch.
  • S3: proven in tests/execution/test_reduce.py (table below).
  • S4: unchanged. The web and MCP scan already names executor.reduce.
S3: which rails bite a sell (checked against guards.py)
RailOn a reductionTest
1 allowlistvetoes..._vetoes_the_reduction_before_the_venue_quotes[1-halal_allowlist]
2 per-order capsliced, not vetoed; a control shows the unsliced order is vetoedtest_rail_2_is_sliced_not_vetoed_and_the_legs_are_recorded
7 min movenot evaluated (stop is None)test_rails_9_and_10_are_satisfied_by_construction
9 stop wideningnot evaluated: no stop or protective_stop, even with a high open_stopsame
10 sell-only-on-rulesatisfied: rule_kind = reduction.reason, never emptysame
12 kill switch / stale feedvetoes[12-kill_switch], [12-stale_data]
18 settlement currencyvetoes (BTC-EUR)[18-settlement_currency]
19 spot shapevetoes (BTC-PERP-USD)[19-spot_instrument]
21 base balancevetoes when the venue affirms 0test_rail_21_vetoes_when_the_venue_affirms_a_zero_holding
3, 4, 5, 6, 8, 11, 13, 14, 16, 17, 20, 22do not bite: all are is_buy-gatedtest_buy_scoped_rails_do_not_veto_a_sell arms all twelve. A control BUY proves each one fires, and then the reduction previews.
Carried-over items (each test-first)
  • (a) Reduction refuses a non-finite qty or expected_price with ValueError. Before this, Infinity passed and NaN raised InvalidOperation.
  • (b) insert_sell_proposal refuses a key that is not a column, including id.
    • Ruling: the check sits in the repository method, not the writer. Its sibling update_sell_proposal already refuses unknown keys, and the table reads NULL as “not recorded”, so a misspelt figure would otherwise be stored silently as missing. insert_order drops unknown keys, but it is not this table’s sibling.
  • (c) The preview’s sized quantity never exceeds the holding.
    • It is capped at holding.qty, then clamped to the venue’s Balance.total. A below_one_increment veto records that capped sale, not the rule’s ask (#911).
    • Every proposal records rails.unverified_fill_orders: the unsized filled live BUYs that own a currently open lot (#900, refined in #912).
      • Every filled live BUY is a candidate owner. It is matched on its booked size (filled_quantity or qty) against the lot’s original size (qty + realized_qty), within one day before the lot opened.
      • Candidates are taken newest first, and each lot is claimed once.
      • Only the unsized owners are listed.
    • positions has no link to its entry order, so this is a match on size and time, not a stored link.
Rulings

…truncated — full description in #910.

feat(agent): _handle_reductions — arbitration, one proposal per product per day, sleeve.proposal notifications (#857) (#914)

Refs #857 — plan P8 (docs/superpowers/plans/2026-09-28-dca-sleeve-sell-side-build.md, Tasks 8.1-8.3).

What
  • Task 8.1 — agent._sleeve_rules(repo, config): sleeve-sell rows are sorted out by their registered class before any row is built (R31), loaded at paper + live in a live cycle and paper only in a paper cycle (R16). Their products join the poll set. A sleeve row that fails to build is logged and skipped, never raised out of run_once.
  • Task 8.2 — agent._handle_reductions(...) and LoopResult.reduce_results. Per product: nothing held asks nothing; one proposal per product per UTC day (R14, sleeve.already_proposed_today); each rule waits for its own bar; a raising rule costs only itself; fixed arbitration (sleeve.ARBITRATION_ORDER) records losers superseded; the sleeve caps (sleeve_refusal) refuse before any intent and record vetoed with rails.sleeve; then executor.reduce (preview-only), offline with no broker on a paper cycle (R18).
  • Task 8.3 — sleeve.proposal (execution, info) in keel_core.notifications.EVENTS, derived by events_from_state(..., sleeve_proposals=result.reduce_results) (R19). preview and vetoed notify; superseded does not. The notification write list is unchanged ([NOTIFIED_WINDOWS_KEY]). Runbook table updated.
  • P7 held question, fixed: a sliced proposal now records the sale’s total — rails.total_qty on the row, ReduceResult.total_qty on the result, and “sell over legs” in the notification. The row’s qty is still rail 2’s first leg.
Cycle ordering (the load-bearing change from the plan)
kill switch / session gate -> poll -> reconcile -> equity -> entry pre-pass
  -> main pass per product: exits, then entries (the DCA buy)
  -> stop management -> venue-holdings record
  -> REDUCTIONS (every product, wrapped per product)          <- new, LAST
  -> LoopResult -> notifications

Pinned by tests/test_agent.py::test_reductions_run_after_the_entries_so_a_denied_sell_preview_cannot_stop_todays_buy: the sleeve product is ADA-USD, which sorts before BTC-USD, and its SELL preview raises TradeScopeDenied. The test asserts the BTC BUY is placed before the ADA SELL is quoted, the refutation is recorded, and the next cycle’s BUY is vetoed by rail 20. A mutant that moves the step into the per-product main pass (the plan’s placement: after exits, before entries) fails this test; the test was built so it would, after a first version with an ETH sleeve product did not catch that mutant.

Deviations from the plan

…truncated — full description in #914.

feat(rules): reverse_dca, preview-only (#857) (#918)

Refs #857

P9 of docs/superpowers/plans/2026-09-28-dca-sleeve-sell-side-build.md: the first sell-side rule kind, reverse_dca. It is spec §6’s scheduled cash distribution, and it is preview-only.

Safety invariants
  • S1: no new sell reaches the venue without keel autonomy on --sells at a TTY. A live reverse_dca only proposes. Its one path is _handle_reductions → executor.reduce (P7/P8), which is preview-only. RUN_ORDER_CALLERS, PLACEMENT_CALLERS, SECOND_LEVEL_CALLERS and REDUCE_CALLERS are unchanged. A new cycle test (test_a_stored_reverse_dca_row_proposes_its_distribution_through_the_real_builder) runs a real stored row through the real builder and asserts no place or cancel.
  • S2: v1 execution is preview-only, and the test is no longer vacuous. ReverseDca declares execution: Execution with Execution = Literal["preview"], as a plain assignment so that rules add’s Literal validation sees it. Its constructor refuses anything else. test_every_sleeve_sell_kind_is_preview_only runs over the registry and fails if no sleeve-sell kind is registered. rules add ... "execution": "auto" exits 1 and writes nothing. The kind is registered (so rules add and _build_rule work), but it is excluded from rules seed, the wizard and research.tuning through agent.seedable_kinds() (R20). rules seed --kinds reverse_dca is refused by name.
  • S3: unchanged. The same guards.check runs on every REDUCE intent.
  • S4: unchanged. No keel/web/ or keel/mcp/ module names a sell-side operation.

The research freeze holds. This PR adds only unit and cycle tests on synthetic candles, and runs no backtest or sweep.

What changed
  • keel/strategy/rules/reverse_dca.py (new) implements spec §6:

    • epoch-aligned cadence, the same as dca’s;
    • a price floor and a drawdown gate over the lookback_days high;
    • floor_qty;
    • gross sized from the net target as target / (1 - fee - slippage), at the SellCosts the caller resolved;
    • no carry-forward, and last_rejection names the gate.

    Rail-2 slicing, the same-day-DCA exclusion, min_hold_days and one proposal per day stay in the pipeline (R11–R14).

  • agent.RULE_REGISTRY["reverse_dca"] and agent.seedable_kinds(). The callers rules seed, setup.seed_rule_library and research.tuning.SEARCH_SPACES switch to it. The last one runs at import time and would otherwise raise TypeError.

  • tests/strategy/rule_conformance.py:

    • a new ReductionConformanceTests mixin, with an anti-tautology firing fixture;
    • minimal_params(kind), so registry-wide contract tests still cover a kind with required params, pinned against each constructor’s signature.
  • Review Focus 5 (Task 9.3): test_a_sleeve_sell_rule_does_not_count_as_managing_a_position runs end to end through gather_findings on a live and a paper profile, each with a positive control. No filter was added (R37, below). all_rules stays the full row set, per #885, so #880/#881 are untouched.

  • The P8 item carried into this PR is R36. A repeated refusal is recorded daily and notified once. sleeve.repeats_previous_refusal reads sell_proposals, so it needs no new state and the notification layer still writes exactly one key. ReduceResult.repeats_previous is set by agent._flag_repeat and skipped by events_from_state. A 30-day cooldown now sends 1 alert, not 29. The first veto, any change of reason, and every preview still notify. The check fails open.

Rulings (recorded in the plan)
  • R36. Notify a vetoed proposal only when its kind or reason differs from the product’s previous non-superseded proposal. The reason is rails.sleeve plus the vetoing rail names (the text before the colon, because the details carry live numbers). A preview always notifies. Cost if wrong: a standing veto is announced once per change of reason, not daily; keel dca proposals list still shows every row.
  • R37. Task 9.3 adds no class filter. Since #897, position.unmanaged keys on (product_id, kind), and a sleeve-sell kind never opens a tranche, so a filter would change no output. The plan’s test therefore passes before any production change. Its non-vacuity is proven by the positive controls and by a mutant that reverts membership to product-only, which turns it red. Cost if wrong: if a future path books a tranche under a sleeve kind, the filter becomes a one-line addition with a red test to drive it.
  • R38. Every registry-wide caller that builds a rule from product_id alone reads seedable_kinds(), and the tests use minimal_params. Cost if wrong: a future sleeve kind is absent from SEARCH_SPACES, which is correct while research is frozen.

…truncated — full description in #918.

feat(dca): keel dca distribute —preview and the reverse_dca doctor findings (#857) (#920)

Refs #857

P10 of docs/superpowers/plans/2026-09-28-dca-sleeve-sell-side-build.md.

What

Task 10.1: keel dca distribute --preview (keel/commands/sleeve_report.py, keel/commands/dca.py)

  • DistributionRow plus distribution_rows(repo, config, now_ts): one row per reverse_dca rule that this profile’s cycle runs (agent._sleeve_rules, R16).
  • next_cadence_ts is the smallest epoch-aligned day d >= today with d % cadence_days == 0.
  • The gates come from the rule’s own reduce_signal. It runs on the cached daily series, with the last bar re-stamped to d. No arithmetic is copied.
  • The size is sliced by sleeve.slice_qty, using the executor’s cached increment and never calling the venue. It is priced at the fallback fee, using record_proposal’s and executor.reduce’s definitions.
  • dca_collision reads the pipeline’s same_day_dca refusal ahead of time.
  • render_distribution produces the output lines.
  • The command opens the DB read-only (_common._open_repo_ro) whether or not it runs at a TTY. It builds no broker (R25) and writes nothing. That is proven by PRAGMA data_version, a per-table row count, and a _build_broker spy that raises.

Task 10.2: sleeve.buy_and_sell_same_asset and sleeve.price_floor_stale (keel/commands/doctor.py)

  • sleeve_rule_findings(rules, closes, *, managed_status) always returns exactly two findings, OK when there is nothing to report (#888). It is wired into gather_findings, and both names are in the exact-set test.
  • The round-trip text is “two taker legs at the venue’s fee”, with no rate.
  • The floor finding WARNs strictly below 0.5 x the latest daily close. It is not judged when there is no close.

Carried item from P8 (R42): feed.stale_open_position now distinguishes two cases.

  • It lives in the alert layer (keel/notifications.py), not in doctor.
  • For a held product that only a sleeve rule watches, it says that rule’s proposals are paused and that no rule exits the product.
  • The key, the WARN and the text for rule-managed products are unchanged.
  • A new watched_by field and LoopResult.sleeve_only_products carry the distinction.
Rulings (recorded in the plan)
  • R39: gates stop at the rule’s first closed gate. Judging the later gates would duplicate the rule’s arithmetic.
  • R40: the collision and the doctor finding count only the rules the cycle runs. That is a live dca beside a paper or live reverse_dca on a live profile, and paper rules only on a paper profile. This narrows the plan’s “any non-disabled dca” so that the preview agrees with the pipeline’s _dca_fires_today.
  • R41: qty, gross and fee cover the whole sale, and legs gives the day count. This matches the sleeve.proposal notification’s total_qty.
  • R42: the stale-feed wording above.
Sample output (paper profile, $100 per-order cap: the $101 gross slices to 2 legs)
rule 1 (paper) BTC-USD next cadence 1970-07-30: sell 0.001013292404198822246898012188 over 2 legs  gross $101.22  fee $1.22 (fallback:config.fees.taker_pct)  gates price_floor=open drawdown=open floor_qty=open
  a dca buy falls on the same day: the pipeline records it vetoed (same_day_dca), and it is not carried forward
preview only: nothing is placed.
Safety invariants
  • S1: unchanged. No new sell reaches the venue. The new command calls no executor placement path and builds no broker. tests/execution/test_sell_side_invariants.py is untouched and green, with RUN_ORDER_CALLERS, PLACEMENT_CALLERS and SECOND_LEVEL_CALLERS unchanged.
  • S2: unchanged. reverse_dca is still Execution = Literal["preview"], and nothing here adds an execution value.
  • S3: unchanged. No rail changed. The preview reads the pipeline’s sleeve refusal ahead of time and changes nothing.
  • S4: unchanged. No keel/web/ or keel/mcp/ module was touched. The command is read-only and not gated. Doctor’s gather_findings, which is shared with MCP, stays pinned read-only by its change-counter test, and the new wiring test also asserts total_changes is unchanged.
Verification
  • TDD: every test was watched failing for the stated reason (ImportError or missing command, a TypeError on the new kwarg, a missing exact-set name, an AttributeError on LoopResult).
  • Mutation: 17 mutants were applied, each confirmed to change the source, run with PYTHONDONTWRITEBYTECODE=1, and restored from a saved copy with the hash checked. All 17 were killed.
  • uv run pytest -q passed: 7570 passed, 3 skipped. ruff check keel tests, ruff format --check and bare mypy are all clean.

feat(sim): the reverse path — distributions in the account sim and the accumulation row (#857) (#922)

Refs #857

P11 of docs/superpowers/plans/2026-09-28-dca-sleeve-sell-side-build.md: the sim’s reverse path. reverse_dca distributions are now booked in the edge pass’s accumulation row and in the account sim.

What changes

Task 11.1, report.accumulation_table: the FIFO-faithful per-rule row

  • The table now processes accumulating rules per asset, over one FIFO pool of lots. Each lot records the key of the rule that bought it.
  • Each dca row reports only its own remaining lots: the qty still held, and the cost net of the basis sold from those lots.
  • Each reverse_dca row has buys=0, qty=0 and cost_usd=0. It carries the new DcaSleeve columns: distributions, units_sold, distributed_usd (net), sell_fees and a FIFO-exact realised_pnl.
  • New parameter max_per_order_usd: Decimal | None = None, which means no cap. keel simulate passes it config.caps.max_per_order_usd, the same cap the account sim and guards.check read.

Task 11.2, the account sim’s reverse path

  • SimAccount.reduce_dca(asset, qty, price, ts, fee_pct) -> (net, fee):
    • It refuses a sale larger than the lot (or of zero, or with no lot) with a ValueError.
    • The sale counts as volume (#86).
    • It releases the sold share of the at-open notional. A lot sold in full is removed.
    • Realised P&L is average cost, against the one averaged lot (R32).
  • portfolio_sim._process_reductions makes at most one sale per asset per UTC day and books it as a DcaSell in SimResult.dca_sells. dca_sleeve totals the sales into the sell columns, and an asset sold down to nothing keeps its row.
  • Parity test (spec §6), in tests/execution/test_reduce.py: the leg the account sim actually fills passes rail 2 in guards.check, and one increment more is vetoed.

Shared decision. portfolio_sim.decide_sleeve_sale is the one decision both sim passes use. It is built from the live pipeline’s own code:

  • the arbitration order sleeve.ARBITRATION_ORDER;
  • sleeve.sleeve_refusal, which covers same-day DCA, min_hold_days on the lots actually consumed, and cooldown_days;
  • sleeve.slice_qty for rail 2, with no venue increment.

Neither slicing nor the refusals are re-implemented. The sim prices a sale at its own configured fee (SIM_FEE_SOURCE = "sim:fee_pct"), and no fee literal is added to keel/.

Rendering. The accumulation and account-sleeve tables add the five sell columns only when something was distributed. A sleeve with no sales renders byte-for-byte as before, which a test pins.

Safety invariants
  • S1: no new sell reaches the venue without keel autonomy on --sells at a TTY. It is unchanged: the sim has no broker and places no orders. RUN_ORDER_CALLERS, PLACEMENT_CALLERS and SECOND_LEVEL_CALLERS in tests/execution/test_sell_side_invariants.py are untouched and green.
  • S2: v1 execution is preview-only. Unchanged; no rule class or Execution literal is touched.
  • S3: rails apply to sells. Unchanged in live. The sim slices with the same sleeve.slice_qty, at the same config cap, and the new parity test shows the sim’s filled leg agrees with guards.check’s rail 2.
  • S4: the browser never gains capability. Unchanged; nothing under keel/web/ or keel/mcp/ is touched.
Research freeze (operator decision, 2026-09-27)
  • The freeze holds. Every new test is a fidelity check of the harness: a hand-computed expectation on synthetic, gapless candles, checking that the sim books a distribution correctly (spec §6, “Evidence status”).
  • No real history is read.
  • No sweep or parameter search is run.
  • No claim about returns is made.
  • Nothing is appended to docs/experiments/trials-ledger.jsonl.

…truncated — full description in #922.

fix(deps): pyjwt 2.13.0 -> 2.14.0 (CVE-2026-102274) (#927)

The `dependencies` check (pip-audit over the exported `uv.lock`) is red on every head since CVE-2026-102274 was published against pyjwt 2.13.0. The fix is in 2.14.0. It blocks PR #922.

  • pyjwt comes in through `coinbase-advanced-py`, which signs the live Coinbase JWT.
  • I pinned it to 2.14.0, the smallest release that fixes the CVE, rather than the latest 2.15.1, to keep the change to live auth small.
  • Only `uv.lock` changes (uv lock --upgrade-package 'pyjwt==2.14.0').
  • Locally, the full suite passes on the new lock: 7516 passed, 3 skipped.
  • It reaches `~/keel` only at the next release deploy.

feat(promotion): the sleeve_sell gate, proposal replay, proposals review, —allow-concurrent-dca (#857) (#928)

Refs #857. P12 of docs/superpowers/plans/2026-09-28-dca-sleeve-sell-side-build.md.

What
  • Task 12.1 — promotion.sleeve_sell_gate (spec §3.7, Q2, Q6). Pure. candidate → paper needs only a clean lookahead on reduce_signal. paper → live also needs SLEEVE_SELL_MIN_PAPER_DAYS = 60 since promoted_at, at least one reviewed paper proposal, and, beside a live dca on the product, --allow-concurrent-dca. Every failing reason is reported at once. A NULL promoted_at refuses rather than reading as the epoch.
  • Task 12.2 — routing in attempt_promotion plus the R21 adapter _reduction_as_detect. A sleeve-sell rule is never backtested for a trade floor; the test stubs backtest.backtest to raise. The adapter maps entry = expected_price, stop = 0, target = qty over a fixed one-lot holding. A deliberately peeking rule is flagged, the shipped rule is clean over the same bars, and a spy pins that every view gets the same holding. rules promote gains --allow-concurrent-dca. --force is refused for a sleeve-sell rule (spec §3.7, R45).
  • Task 12.3 — proposal replay in keel rules backtest for a sleeve-sell rule (sleeve_report.proposal_replay / render_replay). It replays over a synthetic 1-unit lot bought at the first close, using the live pipeline’s own sleeve_refusal (same-day DCA through each dca rule’s own detect, min_hold_days, cooldown) and slice_qty. Each row gets a FIFO realised P&L, and the terminal value is shown with and without the rule. The headline is at config.fees.taker_pct. A second fee line appears only with --fee-sensitivity-pct (no default). A scan asserts \b0\.009\b appears nowhere in keel/ (R29).
  • The rules add --kind reverse_dca hint now works. next: keel rules backtest <id> used to fail with “could not determine a granularity”; it now prints the replay. An end-to-end test parses the printed line and runs it. With no cached daily bars it refuses with a named message instead.
  • Task 12.4 — keel dca proposals review <id>. A [y/N] via click.confirm at a TTY, not a capability row (R23). Interactivity is decided first. Off a TTY it opens read-only, prints the proposal and “not a terminal: nothing written.”, and writes nothing (pinned by PRAGMA data_version). The write is chained as sell_proposal_updated. A superseded proposal exits 1, and an already-reviewed one keeps its first review.
  • The plan gains rulings R44–R48: the sleeve lookahead warmup and the zero-anchor fail-closed rule, the --force/flag refusals, the paper evidence reading, the replay’s first-leg booking, and review’s off-TTY and re-review behaviour.
Research freeze (operator, 2026-09-27)

The proposal replay ships as a fidelity check only. It replays the operator’s one parameter set, runs no search and has no threshold, and appends nothing to docs/experiments/trials-ledger.jsonl. Its output states verbatim that it is “a mechanical replay of this rule’s own proposals over the cached bars — a description, not a pass mark (spec §3.7), and not evidence for choosing parameters”. Every test uses synthetic candles and hand-computed expectations. 0.009 is never written in keel/; the 0.9% row exists only when the operator types --fee-sensitivity-pct 0.009.

Safety invariants
  • S1: no new sell reaches the venue without keel autonomy on --sells at a TTY. Unchanged. The RUN_ORDER_CALLERS/PLACEMENT_CALLERS/SECOND_LEVEL_CALLERS pins are untouched, and nothing in this PR places or cancels. A sleeve-sell rule promoted to live still only proposes (S2).
  • S2: v1 execution is preview-only. Unchanged. --allow-concurrent-dca weakens nothing, because a live sell rule places nothing in this build. The promotion line says so.
  • S3: rails on sells. Unchanged. The replay applies the pipeline’s sleeve caps; it neither bypasses nor models guards.check, and it places nothing.
  • S4: the browser never increases capability. Unchanged. review lives in keel/commands/dca.py, and no keel/web/ or keel/mcp/ module names update_sell_proposal. proposals review is not a capability row (R23): it releases no order.
Deviations from the plan

…truncated — full description in #928.

feat(dca): keel dca trim —preview —view lots and —view bands (#857) (#935)

Refs #857

P13 of docs/superpowers/plans/2026-09-28-dca-sleeve-sell-side-build.md.

What

Task 13.1: lots_view (spec §8.1, Review Focus 3) (keel/commands/sleeve_report.py)

  • LotRow plus lots_view(repo, config, product_id=None): one row per open positions tranche, through sleeve.holding_of. It covers every rule’s tranches (R8), oldest first. That is the order book_exit consumes, so on PAXG turtle tranche 3 is the first row.
  • Each row carries entry_fee_share and cost from Lot, the mark (the latest cached daily close), unrealised, and realised_if_sold. The last is priced by record_proposal’s definitions at sleeve.sell_costs (the fallback fee plus per-product slippage).
  • With no mark, the mark and both P&L figures are None, never zero.
  • render_lots prints a head naming the fee source, the line “FIFO order — a sale consumes these top to bottom”, one heading per product, and “not tax advice” (spec §8.2).

Task 13.2: bands_view (spec §5, read-only)

  • BandRow, BandsReport and bands_view(repo, config). Each target asset’s weight is shown against its renormalised target_weights entry, with the band max(15% x target, 1.5%). A weight is only over or under strictly past the band.
  • When an asset is over, the row shows the size back to target and its fee drag. The drag counts both legs: the sell at the product’s slippage, and the redeploy buy at the underweights’ shortfall-weighted slippage, both at the fallback fee.
  • A held target with no daily close makes the whole report incomplete, with no rows. A held product outside the targets is named on its own line and is not weighed.
  • Research freeze. The view is a display only.
    • Every run prints “band trimming was tested and not adopted (#831) … no performance edge is claimed … keel builds no band_rebalance rule”.
    • Every run also prints “a read-only drift display, not a recommendation”.
    • The rail-14/rail-8 redeploy note is printed too.
    • No band_rebalance rule, sweep, trial or ledger entry is added.

Task 13.3: keel dca trim --preview --view {lots,bands} (keel/commands/dca.py)

  • The command always opens the database read-only (_common._open_repo_ro), builds no broker (R25), and writes nothing.
  • The tests prove this three ways: PRAGMA data_version, per-table row counts, and a _build_broker spy that raises. Each runs for both views, at a TTY and off one.
  • A missing --preview is a usage error. It is named first, because the option is eager. A missing --view is a usage error. --view gain is a usage error until P14 (R24).
  • A case-colliding target_weights (#848) is a clean error, not a traceback.
  • DISTRIBUTE_PREVIEW_FOOTER is renamed PREVIEW_FOOTER and shared by both previews.
Carried items from P12’s held questions (each test-first)
  • (a) The web promotion refuses a sleeve-sell rule (R53). The promote_rule action in keel/commands/setup.py now reads the rule’s registered class over a read-only connection. For a sleeve_sell rule at any status it returns a refusal that names keel rules promote <id> and --allow-concurrent-dca. It starts no job and writes nothing, which PRAGMA data_version pins. It fails closed: a database it cannot read refuses too (review round 2). An entry rule, or an unknown id, still reaches the job; that is the positive control. The S4 web scans are green.
  • (b) The config=None fallback in the sleeve gate is stricter (R54). It now counts any non-disabled dca on the product, labelled non-disabled on the gate line, instead of reading live. It therefore refuses a candidate, paper or live dca without the flag. A disabled dca, or a dca on another product, is not concurrent; that is the control. The comment is fixed.
Rulings (recorded in the plan)
  • R50: both views mark at the latest cached ONE_DAY close, because the sleeve rules decide on that bar. What is absent stays absent.
  • R51: the bands sleeve is the positive target_weights assets’ holdings, renormalised. Untargeted holdings are named, not weighed. BandRow gains value_usd.
  • R52: the two-leg drag prices both legs at the size back to target. The buy leg uses the shortfall-weighted slippage.
  • R53 and R54: the carried items above.

…truncated — full description in #935.

feat(rules): profit_take in preview, and —view gain as trim’s default (#857) (#936)

Refs #857. P14 of docs/superpowers/plans/2026-09-28-dca-sleeve-sell-side-build.md: Task 14.1 (profit_take, spec §4) and Task 14.2 (--view gain, the default), plus the two items carried from P13’s held questions.

Safety invariants
  • S1: unchanged. No new sell reaches the venue. profit_take proposes only through reduce_signal → _handle_reductions → executor.reduce in preview. RUN_ORDER_CALLERS, PLACEMENT_CALLERS and SECOND_LEVEL_CALLERS are unchanged, and tests/execution/test_sell_side_invariants.py passes as it stands.
  • S2: preview-only. profit_take declares Execution = Literal["preview"], and its constructor refuses anything else. The registry-wide test now also asserts profit_take is in the sleeve-sell set, so it is covered and not only present.
  • S3: unchanged. No rail is touched. The trim reaches guards.check through executor.reduce, like every REDUCE intent.
  • S4: unchanged. No keel/web/ or keel/mcp/ module names anything new. The web promote_rule refuses profit_take through the class it is registered under (R53). The existing refusal test is now parametrized over both sleeve kinds.
  • No profit_take promotion is part of this plan (spec §4: “no profit_take rule is promoted”). The kind exists so the trim report and the proposal log can show what a trim would do.

Research freeze (2026-09-27). This PR runs no parameter search and appends no trials-ledger entry. ProfitTake has no param_space(). It claims no performance edge in code, docs or output. The module docstring and the gain report (GAIN_NOT_EVIDENCE, printed every run) both state spec §4’s evidence status: the gain-over-entry trim is untested, and #831 found the drift half no better than static DCA after fees. The tests are hand computations on synthetic candles. There is no 0.009 literal in keel/, and the existing R29 test still pins that.

Task 14.1: ProfitTake
  • On the completed daily close:
    • trigger: close >= vwae × (1 + gain_pct/100), where vwae includes each lot’s prorated entry fee (spec Q4);
    • size: trim_pct (10–20) of the holding;
    • fee gate: qty × (close × (1 − slippage) − vwae) − qty × close × fee >= min_net_usd.
  • last_rejection names the gate that stopped it: gain, fee_gate, nothing_held or no_daily_candles.
  • The drift trigger is not a param. cooldown_days and min_hold_days are read by the pipeline (R15, R12).
  • Registered in agent.RULE_REGISTRY and excluded from seeding by class (R61).
  • Conformance: TestProfitTakeConformance(ReductionConformanceTests).
  • The P12 gate applies through the class alone. Candidate → paper runs the sleeve gate and is never backtested for a floor. A profit_take that never fires is refused (R44). Paper → live needs 60 days and a reviewed proposal. A promotion_class mutant turns all three tests red, and the 4 web-refusal cases too.
  • End to end with the real builder: a stored row proposes one preview trim; the next day’s trim is vetoed by its own cooldown_days; a decline logs agent.reduction_declined with the gate (spec §4: “the reason is logged”).
Task 14.2: keel dca trim --preview --view gain (the default)

sleeve_report.gain_view and render_gain. Every verdict and figure comes from ProfitTake.reduce_signal on the cached daily close, at sleeve.sell_costs. The view only picks the params (R56) and prints them:

  • would trim takes its figures from the Reduction;
  • below fee gate takes them from last_rejection;
  • below trigger prints no sale;
  • no cached daily close gives no verdict.

legs comes from sleeve.slice_qty. The report says every run that the pipeline’s caps are not applied (R57).

CLI changes:

  • --view defaults to gain.
  • --product narrows the gain and lots views.
  • --gain-pct and --trim-pct are checked by the rule’s own constructor.
  • A flag that does not apply to the chosen view is a usage error (R60).

Sample output (the CLI test fixture: synthetic ledger, bars from 1970, so both products are flagged stale):

…truncated — full description in #936.

feat(execution): the sleeve exit monitor — transition alerts, doctor, keel dca exit —preview (#857) (#937)

Refs #857

This is P15 of the sell-side build plan (docs/superpowers/plans/2026-09-28-dca-sleeve-sell-side-build.md). It adds the sleeve exit monitor from spec §7. It only alerts and previews: it never places, cancels or writes an order.

What it does
  • Task 15.1: keel/execution/sleeve_exit.py. A pure classify that places a product at clear, near, breached or insufficient_history against two arms:

    • drawdown: 35% under the 200-day high;
    • sma: three closes in a row under the 200-day SMA.

    near has hysteresis: a product enters within warn_pct and leaves beyond 2× warn_pct. The default levels are module constants taken from spec §7 (R26). monitor_rule and monitor_params read a sleeve_exit rule’s overrides. P16 registers that kind.

  • Task 15.2: agent._watch_sleeve_exits, the last step of run_once.

    • It watches every product that has an open tranche with no resting bracket. It reads the positions ledger, never the unbracketed: retry record, which was #811’s silent channel.
    • It records sleeve_exit:<product> and returns level changes only, on LoopResult.exit_watch_transitions.
    • R27 and R70: a watched product that no rule polls (PAXG tranche 3) gets its own daily history, fetched per product and wrapped. The fetch is a market_feed.backfill over sleeve_exit.history_days (203 days at the defaults), so a cold cache is judged on the history the arms name rather than on one bar. Once the window is full it requests only the newest bar. It never writes last_feed_ts and never joins the products that equity is marked over.
  • Task 15.3.

    • A new sleeve.exit_watch notification (WARN, execution), one per transition. The wording follows the level, and a return to clear reads as a recovery (plan OQ10).
    • doctor.exit_watch_findings (sleeve.exit_watch). With no records it returns one OK sentinel (PR #888), and the name joins the exact-set test.
    • keel dca exit --preview: read-only and builds no broker.
  • Item carried from P14: every sleeve view (gain, distribute, exit) skips a rule row that fails to build and prints one SKIPPED rule <id> (<kind>, <status>) line naming it. Before this, a bad profit_take row crashed the whole gain view (a ValueError traceback).

  • Rulings R63–R71 are added to the plan. R70 and R71 came out of review round 1.

Cycle ordering (pinned)

The cycle runs: reconcile → equity (rail 11) → entries pre-pass → exits and entries (the DCA buy) → _manage_stops → record_venue_holdings → P8’s sleeve-only poll → _handle_reductions → _watch_sleeve_exits (its R27 poll is inside it and wrapped) → notifications.

  • test_the_watch_poll_runs_after_the_buy_and_its_failure_costs_nothing: the index of PAXG’s candle request is after the BTC BUY’s. A PAXG poll that raises still leaves the buy placed and the cycle complete, and the level reads insufficient_history. A mutant that runs the watch straight after the entry poll turns this test red.
  • test_a_monitor_that_raises_costs_neither_the_cycle_nor_the_dca_buy covers the per-product wrap, and test_a_watch_that_raises_before_any_product_costs_nothing covers the outer wrap in run_once.
  • test_a_cold_cache_is_filled_with_the_history_the_arms_need: a cold cache fills to 202 bars, so an SMA break is confirmed in the same cycle. The next day requests only the one new bar.
  • test_the_watch_poll_never_writes_the_rail_12_heartbeat: last_feed_ts is written exactly once, by the entry poll. The test counts the writes rather than reading the value back.
  • test_paxg_tranche_3_is_watched_although_no_live_rule_polls_paxg: the config polls both ONE_HOUR and ONE_DAY, and the watch still polls only ONE_DAY.
Equity is unchanged (pinned)

test_a_held_product_the_watch_polls_does_not_move_equity: PAXG is held at a cost of 80 and no rule covers it. The watch caches a close of 100. Equity is 1,000,080 on both that cycle and the next one, which runs with the close already cached. That is PAXG at cost, so rail 11 does not move. P8’s test_a_sleeve_rule_does_not_move_equity still passes unchanged.

Safety invariants

…truncated — full description in #937.

feat(rules): sleeve_exit, preview-only (#857) (#939)

Plan P16 (docs/superpowers/plans/2026-09-28-dca-sleeve-sell-side-build.md), Task 16.1. Refs #857: it does not close it (ruling R77, below).

What this does
  • SleeveExit (keel/strategy/rules/sleeve_exit.py) is the second layer of spec §7.

    • reduce_signal calls the monitor’s own keel.execution.sleeve_exit.classify at the rule’s params. It returns a Reduction for the whole holding only on breached. near, clear and insufficient_history decline, and last_rejection names the level.
    • Params: dd_pct 35, lookback_days 200, sma_period 200, confirm_days 3, warn_pct 5, arms ("drawdown", "sma"). These are R26’s spec constants, untuned.
  • Registration. It is in agent.RULE_REGISTRY and excluded from rules seed and the wizard by class (R20/R38/R61). minimal_params needs no entry because every param has a default, and the pinning test covers that.

  • Existing gates reach it by its class alone:

    • conformance (TestSleeveExitConformance);
    • the P12 sleeve_sell promotion gate (candidate→paper via the lookahead, refused when it never fires, and paper→live needing 60 days plus a reviewed proposal);
    • the P13 web refusal (R53).

    A promotion_class mutant and a registry-removal mutant each turn these red.

  • R13 is exercised end to end. A tranche bought 2 days ago is still proposed, and a mutant that empties MIN_HOLD_EXEMPT_KINDS turns that red. The same-day-DCA exclusion still applies: a breach on a buy day is recorded vetoed (same_day_dca), and the BUY still goes out.

  • R36. The next day’s identical veto carries repeats_previous, and sleeve.proposal skips it.

  • Rail 2. A whole sleeve over max_per_order_usd becomes 3 legs in the proposal row.

  • R69 is retired (R72). monitor_rule/monitor_params move to the rule module and select isinstance(rule, SleeveExit), using the constructed rule’s validated params. A rule merely named sleeve_exit no longer sets the levels (new agent test). The pure monitor keeps its single import.

Carried items from P15’s held questions
  • (a) Constructor validation (R73). The constructor now refuses:

    • arms that is not a list of distinct known arm names. A bare string ("sma") is refused by name. agent.build_rule_from_params no longer splits a JSON string into letters.
    • non-positive or non-int lookback_days, sma_period or confirm_days, bool included;
    • dd_pct outside (0, 100) and warn_pct outside [0, 100), and either one if it is not finite (NaN, sNaN, ±Infinity).

    rules add writes nothing for any of these.

  • (b) Per-product wrap in exit_watch_view (R74). A watched product whose sleeve_exit: record is not a mapping, has a non-string level or a non-int observed_at, or whose read raises, prints one SKIPPED <product>: ... line. keel dca exit --preview still prints every other product and exits 0.

  • (c) Severity (R75). sleeve.exit_watch is WARN only for near and breached. “Now watched”, “recovered” and insufficient_history are INFO.

    • keel-core fixed severity per key, so notification_event gains a keyword-only severity that may lower an occurrence below its key’s declared ceiling and never raise it. An unknown severity is a ValueError.
    • Delivery routes on the key, not on severity. A test pins a lowered event through send_event.
  • (d) Delivery failure: HELD (R76). There is no retry or outbox pattern to reuse.

    • send_event makes one attempt by design, and sleeve.proposal behaves the same way.
    • #793’s ledger works only because attestation events are re-derived from state. Exit-watch events are transitions.
    • Adopting that ledger would need a second write key in the notification layer, which R19 and tests/test_notifications.py pin to exactly one. That is the operator’s design call.
    • The level itself is never lost: keel doctor WARNs on near/breached every run, and the preview prints it.
Safety invariants

…truncated — full description in #939.

fix(rails): exposure resets at the moment a product goes flat (#943) (#946)

Closes #943.

What

R33 zeroed a fully closed product’s exposure only while it STAYED net flat: _open_exposure_by_asset netted the product’s whole order history and tested qty > 0 once at the end, so the closed round-trip’s notional residual — a real gain or loss, which is not exposure in either direction — came straight back with the product’s next BUY. On live PAXG that is ~$6.36 of phantom exposure one DCA cadence after the first keel positions close, counted by rails 4/5/6 for as long as PAXG is held again. A close at a gain had the opposite sign: $100 really held read as $0.

The fix

The walk is now chronological — (created_at, id) order, which matters because the reset fires at a MOMENT and get_orders returns each status group in id order, not the groups in time order — and the bucket’s notional AND quantity reset to zero the moment its running quantity passes through zero. A product still net long that never went flat nets exactly as before (test_a_partial_close_still_nets_by_notional stays green untouched); one that went flat starts its next position from zero, which is R33’s own rule applied at the only moment it can mean anything.

Tests (written first, shown failing for the right reason: {'PAXG': Decimal('23.606636')} vs 20.00)
  • test_guards.py: re-entry after a full close at a loss (ids deliberately anti-chronological to pin the sort) and after a full close at a gain.
  • test_positions_close.py: the declared-close → next DCA buy integration, the exact path the review reproduced on the live copy.

fix(execution): a watching rule’s fee quote can no longer halt the next day’s buys (#944) (#947)

Closes #944.

What

Two rulings landed together in the build and together they broke the sell side’s own headline guarantee (“nothing added can delay, block or mis-size a buy”):

  • R16 handed a paper-STATUS sleeve rule the live broker on a live cycle — “proposals against the real book” — so a rule that only watches asked the venue for a fee quote.
  • R-P7-2 wrote the trade-scope refutation when that quote was refused with TradeScopeDenied, and rail 20 then vetoed every live entry from the next cycle.

A watching sell rule on a key whose permissions refuse preview calls could therefore halt the DCA program’s next buys.

The fix (the reviewer’s wording, accepted by the operator)
  • R78: a paper-status rule proposes OFFLINE even on a live cycle — offline=True, no broker, fallback fee, offline rails. paper still rides onto the row; the sleeve caps still judge it against the cycle’s own DCA (dca_fires_today keeps the CYCLE’s mode); a LIVE rule’s proposal is still quoted at the venue, unchanged.
  • R79: executor.reduce never writes the trade-scope refutation. A refusal on a sell preview stays on the proposal row (rails.preview_error, WARN executor.reduce_preview_denied); #233’s refutation remains a placement-path fact (_run_order), and the ERROR event an operator greps for when entries stop keeps meaning that.

The original R-P7-2 rationale argued the wrong direction: if the trade scope is really gone, the next BUY’s own preview refuses on its own, exactly as it did before any sleeve rule existed.

Tests
  • test_reduce.py: the denied preview no longer refutes the credential, and the next DCA buy clears the rails.
  • test_agent.py: the cycle-ordering test now pins that a denied sell preview stops NO buy — today’s (ordering) or tomorrow’s (no refutation); the paper-in-live-cycle test pins that the venue is never asked.

Plan doc: R78/R79 with their costs-if-wrong.

fix(execution): a permissions refusal on a bracket leg is definite — rejected and retried, not state-unknown (#945) (#948)

Closes #945.

What

R5 booked every post-row bracket-leg raise the same way: the pending row stayed, the unbracketed: retry was cleared, and a CRITICAL executor.bracket_state_unknown parked the product’s exits on a human — correct for a timeout, where the venue really may be holding the order. TradeScopeDenied was folded into that bucket on purpose, but a permissions refusal is a DEFINITE answer: the venue did not take the order, nothing rests at the exchange, and there is no unknown to reconcile.

The fix (the review’s S-6, R80 in the plan)

A dedicated except TradeScopeDenied ahead of the broad handler:

  • the written row is marked rejected — the status a broker-rejected placement already takes in _run_order, and what reconcile’s own docstring says a broker rejection writes;
  • the unbracketed: retry record is armed (not cleared), so reconcile_unbracketed_positions re-places next cycle and heals the moment the credential trades again — a rejected row is not a resting one and blocks nothing;
  • a CRITICAL executor.bracket_refused names the position: unprotected, loudly, but not unknown.

Timeouts and every other raise keep R5’s state-unknown booking unchanged. _run_order’s refutation write (#233) is untouched — a placement-path refusal is a credential fact; only its row bookkeeping changes. A refusal at the PREVIEW (before any row exists) keeps the pre-row retry path it always had.

Tests
  • test_bracket_downgrade.py: the parametrized place-raise test keeps only the ambiguous timeout; a new test pins rejected-row + armed retry + bracket_refused + no unknown event, and a preview-stage refusal still writes no row.
  • test_reconcile.py: the sweep-level mirror of the #892 timeout test — first sweep refuses (rejected row, retry survives, escalation says retry_scheduled=True), second sweep with a working broker heals.

Plan doc: R80 (note: this branch and #944 both append after R77 — trivial conflict, keep R78/R79 then R80).

chore(release): 0.21.0 — the sell side in preview, and three review fixes (#949)

Version bump only: every workspace pyproject.toml 0.20.0 -> 0.21.0 plus the refreshed uv.lock, the same nine files every bump moves (tests/test_packaging.py green locally). Merge this, then run the Release workflow with 0.21.0.

MINOR: new preview-only commands and a rail change of scope; schema 21 -> 22, additive (sell_proposals). P17/P18 — live sell placement — are NOT built; every sell rule remains preview-only.

What lands since 0.20.0:

  • The sell-side build, P1-P16: #889, #896, #898, #901 (prerequisites #799/#811/#798), #905 (#900 forward fix: book at the venue’s filled size), #908-#914 (contracts, sell_proposals schema v22, executor.reduce in preview, reductions running last in the cycle), #918-#928 (reverse_dca, keel dca distribute --preview, the sim reverse path, the CLI-only sleeve_sell promotion gate), #935-#936 (keel dca trim --preview, profit_take), #937-#939 (the sleeve exit monitor, sleeve_exit).
  • #927: pyjwt 2.13.0 -> 2.14.0 (CVE-2026-102274).
  • The end-to-end review’s three fixes: #946 (#943 — exposure resets at the moment a product goes flat), #947 (#944 — a watching rule’s fee quote can no longer halt the next day’s buys; R78/R79), #948 (#945 — a permissions refusal on a bracket leg is definite: rejected and retried, not state-unknown; R80).

Live impact (from the review, unchanged by the three fixes): buys cannot be delayed, blocked, mis-sized or moved by anything here; each buy waits up to 3.5s for the venue’s final fill and books at the filled size; one extra balance read per cycle; the 21 -> 22 migration only adds a table (ran clean, idempotent, and reversible on a read-only copy of the live DB); no notifications configured on live.

Deploy note: build from this clean tag/commit only — the current ~/keel 0.20.0 binary self-reports (DIRTY).

v0.20.0

Voir cette version sur GitHub ↗

Built from a7fe3fab46cf. Version binds to this hash: keel --version reports keel 0.20.0+a7fe3fab46cf [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.20.0-py3-none-any.whl
keel versions

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

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

Configure

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

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

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

Other changes

feat(dca): keel dca plan — propose a DCA schedule within the venue plan’s cap (#846)

PR 1 of docs/superpowers/plans/2026-09-27-dca-plan.md (Tasks 1–6): the plan service and the keel dca plan CLI. The read-only web card (Tasks 7–8) comes in PR 2.

Behaviour
  • keel dca plan --budget <monthly USD> --buffer-pct <fraction> [--cadence-days 7] proposes a weekly DCA schedule.
    • The universe is allowlist ∩ admitted by screen_product ∩ positive target_weights, minus assets that already have a non-disabled dca rule. Weights are renormalised over that set.
    • Every excluded asset is listed with each reason that applies: not_on_allowlist, not_admitted, no_weight, or has_dca_rule. An asset with a DCA rule is shown as “existing, unchanged”.
  • Spend is budget × (1 − buffer). It is checked against rail 14’s monthly buy cap for the deployment’s venue, read exactly as guards.check reads it. A parity test drives guards.check at the cap and one cent over it.
  • Rail 14 is stated as a monthly BUY cap, never a fee waiver (#836). “fee-free” appears nowhere.
  • Fees are estimated at the configured fees.taker_pct and labelled as such. The venue minimum order size is unknown to keel, and the output says so.
  • At a TTY the prompt is [Y] Approve / [E] Edit weights / [N] Cancel.
    • [Y] writes one candidate dca rule per asset through rules.add_rule_row, all or nothing (R2).
    • The command never promotes, never writes paper or live, and never touches an existing rule.
    • A blocked plan does not offer [Y].
  • Off a TTY it prints the plan, writes nothing, and exits 0 when the plan is approvable or 1 when it is blocked.
  • keel/commands/dca_plan.py is a pure service with no click, ready for PR 2’s GET-only reader. keel/commands/dca.py is the thin click front-end.
Rulings in force

R1–R19 as in the plan, with these changes:

  • R7 amended (after #843). Each existing live DCA rule’s monthly commitment uses that rule’s own budget_usd, because the executor sizes each DCA buy from the rule’s size_usd. The commitment is added to spend, and the plan warns, without blocking, when the sum exceeds the cap. A live rule with dip_bonus_pct > 0 gets its own warning, since its buys can exceed the listed amount.
  • R9 withdrawn. There is no “executor sizes DCA from config.dca.budget_usd” warning, and no executor_budget_usd field. A test pins the warning’s absence.
  • #842 note. Nothing in the output describes max_exposure_usd as a DCA limit, and a test pins that.
  • R20 (new). Interactivity is decided once, first. Off a TTY the database is opened with _open_repo_ro (mode=ro), so the preview cannot create a missing DB file or migrate an old schema.
  • Write-loop sqlite3.Error (new). A locked database mid-write is caught and refused, and the refusal names the rule ids already written. This is R2’s stated cost-if-wrong, now made explicit.
Test evidence
  • New files: tests/commands/test_dca_plan.py and tests/commands/test_dca_cli.py. tests/test_rail14_is_a_buy_cap.py gets one test, and SERVICE_MODULES gets two modules.
  • Each task was TDD red then green. Mutations were proven applied, then shown to be killed:
    • a write inside select_universe;
    • config budget used instead of each rule’s own in R7;
    • rail 14 note removed from the render;
    • pre-validation loop removed from apply_dca_plan;
    • rail 14 no-record cap changed.
  • Fixtures are proven to reach the code under test: the fake screen_fn is called 3 times, the patched build_rule_from_params is called for PAXG, the patched add_rule_row is called twice, and _open_repo_ro is called once off a TTY.
  • uv run pytest -q: 6962 passed, 3 skipped, exit 0.
  • uv run ruff check keel tests exited 0, uv run ruff format --check keel tests exited 0, and bare uv run mypy exited 0.
  • Manual sanity run: the template config against a fresh migrated temp DB, off a TTY. It exits 1 with named blockers: every asset is unadmitted, and rail 14 is at $0 with no attestation, together with the attest command. The DB checksum is unchanged.

guards.py and executor.py are untouched.

feat(web): read-only DCA plan card on /rules (#850)

PR 2 of docs/superpowers/plans/2026-09-27-dca-plan.md (Tasks 7–8). This PR builds on PR 1 (#846) as merged, not on the plan’s snippets.

What it does
  • GET /api/dca-plan?budget=&buffer= answers from the same service as keel dca plan (build_dca_plan). The admission screen is resolved per call.
    • It is read-only and writes nothing, which is pinned by a rules-row count after a request. It is GET only; a POST gets a 404.
    • Money crosses the wire as strings, values arrive presentation-ready, and state is an explicit field.
  • No budget: the route answers an awaiting_budget payload with the same keys as the ready one, every figure absent, and the command template.
  • The worst-month cap check (#847) is on the wire and on the card.
    • cap_check is the worst UTC calendar month rail 14 was compared against, with the checked total as its value.
    • It is judged bad over the cap, good within it, and neutral when the tier is unlimited.
    • The card shows it as “checked against the cap: worst calendar month for a 7-day cadence, 5 buy day(s) x $103.47 per cycle = $517.35”.
  • The card on /rules sits under the rule ledger and reads the page’s own address: /rules?budget=500&buffer=0.1.
    • It shows the summary figures, the per-asset schedule, the excluded assets with their reasons, the existing DCA rules (unchanged), the blockers, the notes, and the exact CLI command with --config/--db for the served deployment.
    • It has no button, anchor, form or input, and no event handler.
  • A refused read (a bad ?budget=, or a config the service refuses) leaves the ledger standing. The card says it could not read the proposal and places the server’s reason, in the service’s own words.
Rulings
  • R21: every DcaPlanError is a 400 ApiRefusal. This includes the ones build_dca_plan raises over the config (target_weights keys colliding by case, a non-finite weight).
    • Why: ApiRefusal is this module’s only way of saying “declined, and here is why”. The CLI refuses the same error as a clean message. A 500 would read “That report could not be built” over a message that already names the fix.
    • Cost if wrong: a config problem is reported with a client-error status. The body still names the config key to fix.
  • R22: the worst-month sentence is written once, by a new service function dca_plan.worst_month_text(plan). The CLI’s checked against the cap: line and the payload’s cap_check.display both use it.
    • Why: two front-ends wording, or re-totalling, the figure the blocker compared is the #847 drift.
    • Cost if wrong: a one-function change to PR 1’s module. The CLI line is byte-identical, and a test pins that.
  • R23: the card receives the secondary reading’s error as well as its data (rulesView(data, sort, onSort, plan, planError)).
    • Why: with data alone, a 400 would show a bare “could not be read” and throw away the reason, such as the fraction hint for buffer=10.
    • Cost if wrong: one extra parameter.
  • R24: no service-worker cache bump. The worker’s cache name is keel-shell-<full_version>, which is <version>+<commit>, so every new commit is a new cache. /api/ is never cached, and a new test pins that every API_ROUTES path sits under API_PREFIX.
    • Why: earlier web PRs changed static JS without a bump for the same reason.
    • Cost if wrong: none; the mechanism is structural.
  • R25: the card’s three row collections (buys, excluded, existing) are enrolled in the row-key scan, not exempted. A seeder patches the screen to admit BTC and reject PAXG, and writes an existing ETH DCA rule. The endpoint is asked with a budget, so every collection has a row.
    • Why: the plan said not to use an exemption.
    • Cost if wrong: _seed_for now takes monkeypatch.
Test evidence
  • uv run pytest -q: 7021 passed, 3 skipped (exit 0).
  • uv run ruff check keel tests: exit 0.
  • uv run ruff format --check keel tests: exit 0.
  • uv run mypy (bare): exit 0.
  • node --check on render.js and main.js: exit 0 for both.
  • Structural client tests:
    • The card’s tags are on a closed list, with a scan that sees a mutated-in button.
    • Exactly one code element, paired with plan.command.
    • One table, with no sort pair.
    • Every served key and summary figure is paired with a read.
    • Exactly one figure reads plan.cap_check, under “checked against the cap”.
    • The ROUTES row, and the mount call passing both the reading’s data and its error.
    • The page-address seeding: one location.search read, names budget/buffer only, and the dca-plan bag only.
  • Mutations applied and killed, each confirmed to have changed the source before the run:
    • A module-level screen_product import fails the route tests (the patch no longer reaches the reader).
    • Removing the DcaPlanError catch fails the config-refusal test.
    • Renaming row keys (row.fee, item.reasons, rule.per_buy) fails the row-key scan.
    • Dropping the cap_check figure, dropping a table cell, dropping planReading.error, widening the seeded names, and not placing error.detail each fail their test.
  • Browser check (Playwright): a real keel serve on a copy of keel/templates/config.yaml with a temp DB, on a free port. The service worker was unregistered and its caches deleted first.
    • /rules?budget=500&buffer=0.1 shows the blocked state with the real screen: nothing admitted on an empty DB, every asset listed as excluded with the screen’s reasons, and the exact command.
    • /rules with no query shows the awaiting state and the command template.
    • Both loads had 0 console errors or warnings, 0 controls in the card, and 1 code element. The rule ledger stays in place.
    • At 390px there is no horizontal scroll.
    • buffer=10 shows the 400 detail (“…0.1 means hold back 10%”) under the standing ledger. The only console entry is the browser’s network log of that 400.
    • A second server with the screen patched to admit everything and a $600 attested cap showed the populated 7-row schedule, “ready to apply from a terminal”, and the worst-month check $517.20 judged good.
Deviations from the plan
  • The payload has one more key than the plan’s shape: cap_check, the #847 amendment.
  • rulesView takes a fifth parameter, planError (R23).
  • The card’s excluded and existing lists are built with .map over explicit keys, so the row-key scan checks them. The plan had one generic list helper that read every key.
  • The empty-table text is “No buys planned.” rather than “No asset is eligible.”, which was false in the awaiting state.
  • A small .dca-plan .dca-command CSS rule shows the command as a selectable block.

chore(deploy): sync live-rules.json with the live rule set (#855) (#858)

Closes #855

Regenerates deploy/live-rules.json from the live rule set. I made a read-only copy of ~/keel/keel-live.db with Python’s sqlite3 backup API (source opened mode=ro) into a scratch dir, and ran scripts/rule_manifest.py export against that copy. Nothing under ~/keel was written.

Live DCA rules (status live)
idproductbudget per buycadence
6BTC-USD$407 days
8ETH-USD$257 days
9PAXG-USD$2514 days
10ADA-USD$1514 days
11XLM-USD$1514 days
12DOGE-USD$1514 days
13FET-USD$1514 days

All seven have dip_bonus_pct 0 and lookback_days 90.

The turtles are paper. The six turtle_breakout rules (BTC, ETH, PAXG, ADA, XLM, DOGE) are status: paper. The old manifest listed five of them as live.

Rulings
  • Updated test_committed_manifest_is_valid. It pinned exactly one DCA rule and pinned that rule’s params to Dca’s constructor defaults. The regenerated manifest breaks both: there are 7 DCA rules, and none uses the default $50. The test comment itself says to update it when this happens. It now asserts:
    • every DCA rule is live;
    • no rule is candidate;
    • every DCA rule differs from the constructor defaults on at least one param, so a keel init reseed would show as a change in value, not only a change in status.
    • Why: the brief expected the test to pass as-is (#843/#844 dropped the budget pin), but the count pin and the defaults-coincidence pin were still there.
    • Cost if wrong: one test’s assertions need re-tightening. No runtime effect.
  • Refreshed one sentence in scripts/rule_manifest.py’s module docstring. It said the live DCA rule is 50 on purpose, matching the default. That is no longer true.
    • Why: keel keeps its rules in docstrings, and a stale one misleads.
    • Cost if wrong: none.
Test evidence
  • uv run pytest -q: 7027 passed, 3 skipped (exit 0)
  • ruff check keel tests scripts: exit 0
  • ruff format --check keel tests scripts: exit 0
  • mypy: exit 0
  • Mutation checks. For each, I confirmed the file differed from a saved copy before running, then restored it from that copy:
    • set BTC budget_usd to "50", so the rule equals the defaults: the value assertion fails, naming BTC-USD;
    • set one DCA rule’s status to paper: the status assertion fails.

docs(spec): DCA sleeve sell side — trims, rebalancing, reverse DCA, protective exits, rotation (#857) (#859)

Design spec for #857, docs only. The issue stays open for the operator’s review; nothing here is implemented.

Spec: docs/superpowers/specs/2026-09-28-dca-sleeve-sell-side-design.md

What it decides

One architecture for five sell-side features that all sell from the same unbracketed DCA holdings:

  • Action.REDUCE and Rule.reduce_signal. Today Rule can only express “buy” or “sell all”, position_rule:<product> has one owner (dca, whose exit is hardcoded False), so no live path can sell a DCA tranche at all. Every sell-side feature becomes a named rule kind (profit_take, band_rebalance, reverse_dca, sleeve_exit, rotation) on a rule row, which is what satisfies rail 10 and gives the audit trail its vocabulary.
  • Lots and average entry come from the positions ledger (FIFO, entry fees in the basis), never from orders; two doctor findings surface drift between the two and against the venue.
  • Rails per leg. No rail is added, relaxed or DCA-exempted for sells. Rail 2 gates sells too, so a whole-sleeve sale above max_per_order_usd is sliced one leg per cycle. The redeploy BUY leg of a rebalance or rotation meets rails 8 (no averaging into losers) and 14 (the monthly buy cap that now bounds DCA) as a non-DCA buy.
  • A separate sells gate. Sell-side rules ship execution: preview; placing needs a typed yes at a TTY per proposal, or execution: auto plus a new keel autonomy on --sells window. The live autonomy flag does not release the new sell class. No web or MCP verb.
  • Audit: a sell_proposals table for every evaluated reduction, placed or not, chained into audit_events; trade_outcomes.is_dca derived from the tranches so rail 16 never counts a sleeve sale.
  • Promotion: a sleeve_sell class with a proposal-replay description and a 60-day paper period, because these rules have no R for the backtest gate.
Per-feature verdicts
#FeatureVerdict
1Profit-taking and trimmingpreview-only: keel dca trim --preview; the drift trigger is #831’s arm D (not better); the gain-over-entry trigger is untested and would be accumulation policy’s 4th pre-registered trial
2Steering and band rebalancingdon’t build (yet): #831 arms C and D, pre-registered and bootstrapped, are not better than static DCA after fees; the read-only bands view is folded into the trim report
3reverse_dcabuild, in preview: a spend plan, not an edge claim; gates on price floor, drawdown, floor quantity, same-day DCA; refuses to go live beside a live dca on the product unless typed
4Protective exit monitorpreview-only alerts on level transitions, after #811 and #799 land; never an automatic sell, since a structural stop negates the sleeve’s accumulate-through-drawdown thesis
5Rotation and tax-loss harvestingdon’t build (yet): no jurisdiction anywhere in the repo, so no tax number can be computed; the KB flags sale-and-buyback as a fabricated round trip (§65.11), recorded as a fiqh finding; build keel pnl --lots and #798’s positions close instead
Evidence the requests ran into
  • Requests 1 and 2 were measured by #831 (docs/experiments/2026-09-27-accumulation-policy.md): no arm beats static DCA under D6; band trimming’s +13.5% on the one historical path is taken back by the bootstrap (36–43%).
  • The request’s 1.20% fee is the sim figure; live pays 0.9% taker. Live gates use the venue’s previewed commission with 1.2% as the recorded fallback.
  • Rail 14 is a buy cap and never touches a sell, but the redeploy leg spends it.
  • #811’s doctor findings are not implemented yet; #799 is a recording defect, not the monitor gap. Both are prerequisites for feature 4.

Refs #857. Not “Closes”: the operator reviews the recommendations and the twelve open questions in §12.

feat(dca): a cap-blocked plan names the largest passing budget and buffer (#854) (#867)

Closes #854

When rail 14’s worst-calendar-month check blocks a plan, the blocker now names two values:

  • the largest --budget that passes at the given buffer;
  • the smallest --buffer-pct that passes at the given budget.

One service function, _passing_suggestion_text, builds that clause. plan.blockers carries it, and the CLI (render_dca_plan) and the /rules card (dca_plan_payload) both show it unchanged.

The issue’s worked example ($500 budget, 0.10 buffer, weights .4/.3/.3, 7-day cadence, $500 cap) now reads:

planned spend $450.00/month; the worst calendar month for a 7-day cadence holds 5 buy day(s), which at $103.47 per cycle is $517.35 — that exceeds rail 14’s monthly buy cap $500.00 on coinbase; the largest —budget that would pass is 483.26; the smallest —buffer-pct that would pass is 0.1302

How
  • _per_buy_usd is factored out of build_dca_plan’s loop. _worst_month_spend_for is the whole R5 pipeline: spend rounded down, each per-buy rounded down, summed, then multiplied by the worst month’s buy days.
  • Two searches find the values: one over cents for the budget, one over 0.0001 steps in [0, 1) for the buffer. Each search calls that same pure function, never a closed-form inverse, so a suggestion cannot drift from what a re-run computes. Each boundary is checked on both sides.
  • DcaPlan.max_passing_budget_usd and DcaPlan.min_passing_buffer_pct hold the values (or None). The text is built from those fields, and they are formatted with format(x, 'f'), so they never print with an exponent.
  • New ruling R20 is in docs/superpowers/plans/2026-09-27-dca-plan.md and in build_dca_plan’s docstring.
Rulings
  • Existing live commitments (R7) are reserved before the search. The searches subtract the existing live DCA rules’ own worst-month commitment from the cap first, and the clause names that reservation whenever it is above zero.
    • Why: the brief says to include R7. The R6 blocker itself compares only this plan against the cap; R7 only warns. So the suggestions are stricter than the blocker.
    • Cost if wrong: when live rules exist, the suggested budget is lower than what would clear the blocker alone. The operator can still go higher and accept the R7 warning.
  • Deviation: “one step past is blocked” holds only when there are no live rules. With live rules present, one cent past the suggestion clears the blocker but raises the R7 combined-total warning. The test asserts exactly that.
    • Why: making R7 a blocker would reverse the operator’s R7 ruling (warn, don’t block).
    • Cost if wrong: if the operator wants a strict blocker boundary here, the clause should drop the reservation. That is a one-line change.
  • Suggestions also appear on the degraded-cap branch (cap unattested or lapsed, $0).
    • Why: the brief names “cap $0” as a case that must say “no buffer passes”.
    • Cost if wrong: extra text beside the “attest” instruction.
  • When nothing passes, the clause says so in words. It reads “no positive —budget would pass” or “no —buffer-pct below 1 would pass” instead of giving a number.
    • Why: this is in the brief.
    • Cost if wrong: none.
  • The searches keep internal asserts on their boundary.
    • Why: monotonicity is proven in the docstring. The assert turns any rounding surprise into a loud failure rather than a wrong suggestion.
    • Cost if wrong: a surprise would give the web reader a 500 instead of a slightly-off number.
  • payload.py is unchanged.
    • Why: the card already passes plan.blockers through unchanged, so the shared text reaches it for free.
    • Cost if wrong: if the card later wants the values as separate fields, that is a follow-up.
Test evidence
  • The worked example pins 483.26 and 0.1302.
  • The re-run proof calls build_dca_plan at each suggested value and asserts approvable. At one cent more on the budget, or 0.0001 less on the buffer, it asserts exactly one blocker, and that the plan carries the same suggestion.
  • With a live rule: the suggestions pin 434.94 and 0.2172. At the suggested budget, neither the blocker nor the R7 warning fires. One cent more raises the R7 warning. Replaying the suggestion computed without the live rule also raises the R7 warning, which shows the reservation changes the result.
  • The two “nothing passes” cases each have a test: existing commitments already over the cap, and a $0 cap with a large budget.
  • A test checks that the values never print with an exponent.
  • Mutation check: I made the budget search return one cent too many and confirmed with cmp that the file changed. Three tests failed, including the re-run proof (the plan was still blocked at 483.27). I restored from a saved copy and cmp confirmed it was identical.
  • Full checks after the final commit:
    • uv run pytest -q: 7033 passed, 3 skipped (exit 0)
    • ruff check keel tests: exit 0
    • ruff format --check keel tests: exit 0
    • mypy: exit 0
    • no JS changed.
  • Not strict TDD: the worker got the pinned numbers from a standalone spike instead of watching those tests fail first. The re-run tests don’t depend on those numbers, and the mutation check shows they fail when the search is wrong.

Touches keel/commands/dca_plan.py, which #853’s PR also edits. Expect a small merge conflict near the blocker code.

fix(rails): exempt DCA buys from rail 6; dca plan checks rails 3 and 5 (#853) (#869)

Closes #853

DCA buys are now exempt from rail 6 (per_asset_concentration_cap), just as #842 exempted them from rail 4. Rail 14 (the monthly BUY cap, not a fee waiver) and available cash are now DCA’s aggregate bounds. keel dca plan now checks the rails it can evaluate offline that still bind DCA.

Changes
  • keel/execution/guards.py: rail 6 fires only for is_buy and not intent.is_dca, mirroring rail 4. I updated the module docstring and the inline comment block. DCA holdings still count in the exposure that rails 4 and 6 measure for rule trades.
  • keel/sim/account.py: SimAccount.can_open has the same exemption, so keel simulate models live, and its docstrings are updated.
  • keel/commands/dca_plan.py:
    • Rail 3 (per day) blocker, R21. The busiest day is the planned per-buys plus every existing live DCA rule’s own budget_usd, compared against caps.max_per_day_usd. per_day_cap_text builds the sentence.
    • Rail 5 (correlated) warning, R22. A per-buy above max_per_order_usd x CORRELATED_SIZE_SCALE, for an asset outside UNCORRELATED_ASSETS, raises a warning. Both constants are imported from guards.py. correlated_size_text builds the sentence.
    • The CLI (render_dca_plan) and the /rules card (dca_plan_payload) both show plan.blockers and plan.warnings unchanged.
  • Docs:
    • new docs/rails/rail-6-per-asset-concentration.md, in the rail-4 page’s format, with the exemption table and a note that DCA holdings still count;
    • updated the rail 4 and rail 14 pages, docs/operator-runbook.md, and the plan doc (the #842 note amended, R21/R22 added);
    • updated the caps: comments in keel/templates/config.yaml and keel/templates/config.live.yaml;
    • re-synced root config.yaml; cmp shows it is byte-identical to the template.
Rulings
  • Rail 6 uses rail 4’s gate exactly.
    • Why: #841/#842 set the operator-approved pattern for this exemption.
    • Cost if wrong: DCA per-asset size is bounded only by rail 14 and cash.
  • The rail 3 busiest day is the plain sum of all planned and live per-buys, with no phase search.
    • Why: Dca.detect is epoch_day % cadence_days == 0 with no offset, so on a multiple of the lcm of the cadences, every rule buys.
    • Cost if wrong: if Dca ever gains a phase, this blocker becomes conservative, blocking plans that would actually pass.
  • The rail 3 text says “in DCA buys alone” and that rail 3 counts every BUY.
    • Why: the plan knows only DCA rules, so the figure is a lower bound on that day’s spend.
    • Cost if wrong: none.
  • Rail 5 is a warning and names each correlated asset. PAXG is never named.
    • Why: the rail binds only while other correlated exposure is open, which a plan cannot see.
    • Cost if wrong: an advisory note.
  • The rulings are numbered R21/R22.
    • Why: there is no R22 in the plan doc for the brief to have meant. #854’s PR takes R20. “One service function per sentence” is implemented as per_day_cap_text and correlated_size_text, following worst_month_text.
    • Cost if wrong: numbering churn in a planning doc.
  • The existing sim parity tests are unchanged apart from a comment.
    • Why: their $350 boundary is decided by the per-order cap on both sides, so parity still holds.
    • Cost if wrong: those two tests assert less than their old comment claimed, and the comment now says so.
Test evidence
  • guards, exact violation sets:
    • a DCA buy over the per-asset limit gives [];
    • the same buy with is_dca=False gives {"per_asset_concentration_cap"}, which also shows DCA holdings still count;
    • a sell gives set() for both classes.
  • Still binding a DCA buy: rail 2 → {"per_order_cap"}, rail 3 → {"per_day_cap"}, rail 5 → {"correlation_adjusted_sizing"}, rail 13 → {"usdc_funding"}, rail 14 → {"monthly_subscription_allowance"}.
  • SimAccount parity: DCA is exempt; a non-DCA buy is still bound.
  • Plan:
    • the busy-day figure for the worked example is $103.47;
    • at the cap exactly, the plan is approvable; one cent over, the blockers tuple equals exactly (per_day_cap_text(...),);
    • existing live rules push the day over the cap;
    • candidate rules are not counted;
    • the rail 5 warnings list exactly BTC then ETH, never PAXG;
    • the CLI renders the blocker line exactly once.
  • TDD: the worker wrote the tests first and reverted the gate to show the failures, e.g. Left contains one more item: 'per_asset_concentration_cap: BTC exposure 1005.0 exceeds ...'. The plan tests failed against the pre-#853 dca_plan.py with AttributeError: ... 'worst_day_cycle_usd'.
  • Mutation checks. For each, I confirmed with cmp that the file changed, then restored it from a saved copy:
    • dropped and not intent.is_dca from guards and from sim: the new tests failed;
    • changed > to >= on the day-cap boundary: the boundary test failed;
    • removed the UNCORRELATED_ASSETS exclusion: the PAXG assertion failed;
    • passed live_rule_count=0: the existing-live test failed;
    • dropped the rail 5 warnings.append: the warning test failed.
  • Full checks on 44c6f1c:
    • uv run pytest -q: 7044 passed, 3 skipped (exit 0)
    • ruff check keel tests: exit 0
    • ruff format --check keel tests: exit 0
    • mypy: exit 0
    • no JS changed.

Conflicts: #854’s PR (#867) and #856’s PR also edit keel/commands/dca_plan.py.

feat(web): cache the DCA plan’s admission screen for 5 minutes (#856) (#870)

Closes #856

GET /api/dca-plan now caches the admission screen (build_screen_report’s result) for 5 minutes, keyed on the served deployment’s (config_path, db_path) plus the screened product ids and quote currency (#873). The payload carries a presentation-ready screen_age field, and the /rules card shows it as a note, e.g. “Asset screen from 2m ago; refreshes every 5m.” (the interval is ScreenCache.TTL_SECONDS, handed through)

Changes
  • keel/web/api.py: a new ScreenCache with a lock and TTL_SECONDS = 300. read_dca_plan asks the cache for the screen, then builds the plan from it.
  • keel/web/server.py: ServeConfig.screen_cache holds one cache per keel serve process, shared by all of ThreadingHTTPServer’s threads.
  • keel/commands/dca_plan.py: select_universe and build_dca_plan accept a screen_report_fn, defaulting to build_screen_report. The CLI’s behaviour is unchanged.
  • keel/web/payload.py: screen_age is a Field whose value is the age in seconds and whose display is the sentence. The awaiting-budget state (and any call with no screen age) sends a worded not_screened field (“Asset screen: not run yet — it runs once a budget is entered.”), so the card never shows a lone dash.
  • keel/web/static/js/render.js: dcaPlanCard places field(plan.screen_age) in a p.note under the state line.
Rulings
  • The cache holds the screen, not the plan.
    • Why: budget and buffer are per-request query params, and a cached plan would serve stale buy amounts.
    • Cost if wrong: none; plan arithmetic is cheap.
  • The clock is injected as the reader’s own now_ts, not as a new callable.
    • Why: test_no_reader_reads_the_clock_a_second_time forbids a second clock read in a reader. now_ts is already the injected clock, so the tests drive the TTL through it.
    • Cost if wrong: a reviewer who wanted a Callable[[], int] would need a small signature change.
  • The key is (config_path, db_path), even though the cache already lives on the per-process ServeConfig.
    • Why: the cache stays correct even if one instance ever serves two deployments.
    • Cost if wrong: none.
  • An entry is fresh while its age is under 300 s; at exactly 300 s it is rebuilt.
    • Why: this matches guards.py’s due <= now convention.
    • Cost if wrong: a one-second difference.
  • The lock is held while a miss rebuilds.
    • Why: this prevents a thundering herd when concurrent polls land on a stale entry.
    • Cost if wrong: one poll waits for a few local DB reads.
  • The age is clamped at 0. I added this after the worker finished.
    • Why: a request stamped before a later-stamped request built the entry (it waited on the lock) showed “-1s ago”.
    • Cost if wrong: none.
  • No service-worker cache bump.
    • Why: sw.js?v=<build> is keyed on the commit, so a new commit already invalidates old clients.
    • Cost if wrong: a stale shell until the next build.
Test evidence
  • Each test counts calls to build_screen_report, which also shows the fixture reaches read_dca_plan.
    • A hit within the TTL keeps the count at 1 and reports age 299.
    • Exactly 300 s later the count is 2, and 600 s later it is 2.
    • A different config path at the same instant makes the count 2.
  • ScreenCache is also unit-tested directly:
    • the 0/299/300 s boundary;
    • four keys (two config paths × two db paths);
    • 8 threads racing a cold cache build exactly once;
    • a request stamped before the build reads age 0. This test failed first with -1 == 0, and passes after the clamp.
  • The payload’s screen_age is asserted as an exact field dict, and the awaiting state as absent.
  • The client test asserts the pairing: one el("p","note") that appends field(plan.screen_age) and is appended to the card.
  • TDD: the worker’s tests failed first with AttributeError: ... 'screen_cache' / no attribute 'ScreenCache', and the card test failed with “sends screen_age; the card never places it”.
  • Mutation checks. For each, cmp confirmed the file changed, and the file was restored from a saved copy:
    • < to <= on the TTL: the two boundary tests failed;
    • key (db_path, db_path): the key-isolation test failed.
  • Real browser (Playwright): keel serve ran on a copy of keel/templates/config.yaml with a scratch DB on free port 57711.
    • I unregistered the service worker and cleared its caches first.
    • /rules?budget=500&buffer=0.1 showed “Asset screen from 0s ago; refreshes every 5m.” with 0 console errors and 0 warnings; /rules with no budget shows the not-run-yet line (re-verified at dd5fa82 after unregistering the service worker).
    • A reload 53 s later showed “from 53s ago”, which shows the cache survived in the running server.
    • The server was then killed.
  • Full checks on a34c73f:
    • uv run pytest -q: 7038 passed, 3 skipped (exit 0)
    • ruff check keel tests: exit 0
    • ruff format --check keel tests: exit 0
    • mypy: exit 0
    • node --check keel/web/static/js/render.js: exit 0

Conflicts: this PR, #853’s PR and #854’s PR (#867) all touch keel/commands/dca_plan.py, but in different functions.

fix(dca): the passing-value suggestion clears rails 3 and 2 as well as 14 (#874) (#876)

Closes #874

What changed
  • _within_budget_rails (new, private) is the passing predicate for both binary searches. A candidate --budget / --buffer-pct now has to clear three rails:
    • rail 14: the worst month within the cap, minus the R20 reservation for live DCA commitments (kept, per the orchestrator ruling);
    • rail 3: worst_day_cycle + existing_live_daily <= max_per_day_usd, the same busiest-day sum #869 uses;
    • rail 2: every per_buy <= max_per_order_usd.
  • All three are monotone in budget and buffer, so both searches stay exact, and the boundary asserts are unchanged.
  • The _passing_suggestion_text clause is now appended to every budget-driven blocker: rail 14, rail 3 and each rail 2. The CLI and the /rules card both show plan.blockers verbatim, so they share the one text function.
  • When no value passes, the clause says so (“no positive —budget would pass …”).
  • On an unlimited tier nothing is reserved against a monthly cap.
  • Ruling R23 is added to docs/superpowers/plans/2026-09-27-dca-plan.md. No public names change.
Evidence

Red first. All five new tests failed on main. The pre-fix suggestion, re-run through build_dca_plan, is still blocked:

  • max_per_day_usd=60: 483.26 / 0.1302 → 1 blocker (rail 3).
  • max_per_order_usd=30: 483.26 / 0.1302 → 1 blocker (rail 2, BTC $40.00).

New tests. Each asserts the exact blockers tuple, never substrings.

  • test_the_issue_874_suggestion_clears_rail_3_as_well_as_rail_14 and test_the_suggestion_clears_rail_2_as_well_as_rail_14:
    • re-running at the suggested budget and at the suggested buffer gives blockers == ();
    • one cent above the budget, or 0.0001 below the buffer, is blocked by exactly the one rail-3 (or BTC rail-2) blocker.
  • test_a_rail_3_blocker_on_its_own_now_names_a_passing_value and test_a_rail_2_blocker_on_its_own_now_names_a_passing_value.
  • test_rail_3_says_no_value_passes_when_live_rules_alone_fill_the_day (unlimited tier).
  • Three existing rail-3 tests now expect the appended clause.

Mutation check. Each mutant was applied to the source (a 2-line diff, verified), and each was killed:

  • rail-3 term removed: 7 tests fail, including all three new rail-3 tests;
  • rail-2 term (all(per_buy <= max_per_order_usd ...)) replaced by return True: both new rail-2 tests fail.

Local checks (exit codes): pytest=0 (7074 passed, 3 skipped), ruff check=0, ruff format --check=0, mypy=0.

Live is not affected: per-order is $200, per-day is $200 and the worst day is $150.

chore(release): 0.20.0 — DCA exempt from the per-asset rail; dca plan and its read-only card (#877)

Version bump only: all 8 pyprojects, their == pins, and uv.lock go from 0.19.0 to 0.20.0. That’s 9 files, the same shape as #845. uv lock --check passes, and there’s no schema change since 0.19.0.

MINOR, because rail 6 changes scope for DCA (#869), and a new command and route land (#846, #850).

  • #846: keel dca plan
  • #850: GET /api/dca-plan and the read-only card on /rules
  • #869 (#853): DCA buys are exempt from rail 6; the plan blocks on rail 3 and warns on rail 5
  • #867 (#854), #876 (#874): a blocked plan names the values that pass rails 14, 3 and 2
  • #870 (#856): the admission screen is cached for 5 minutes
  • #858 (#855): the live-rules manifest is synced
  • #859: the sell-side design spec

Local: pytest 7074 passed, 3 skipped; ruff and mypy clean.

v0.19.0

Voir cette version sur GitHub ↗

Built from 71ddba24c1f1. Version binds to this hash: keel --version reports keel 0.19.0+71ddba24c1f1 [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.19.0-py3-none-any.whl
keel versions

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

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

Configure

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

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

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

Features

research: a 200-day SMA filter on the daily turtle — no arm distinguishable from the baseline (#830) (#833)

Closes #830.

Four commits, in the order that matters:

  1. d5e1485 feat(strategy): an optional trend_filter on turtle_breakout (off | above | slope | both, trend_sma_period 200, trend_slope_lookback 5).

    • Default off, so every existing rule behaves exactly as before.
    • Too little history to compute the average declines the entry (trend_filter_history) rather than letting it through.
    • 8 tests, each failing first, using fixtures that assert their own premise. 5 of 5 mutants killed.
  2. 696bc5b The pre-registration. The driver’s docstring holds the arms, data, window, statistics, decision rule and my expectation. It was pushed at 00:39 UTC before any arm ran.

  3. 37cc6ac fix(research): the trials ledger now refuses two kinds of summary value it could never read back:

    • A non-numeric string raises on read, making the append-only ledger unreadable from that row on.
    • A float breaks the row’s hash, so verify_chain reports it as tampered forever.

    Both were found when the first run’s four rows failed to read back. Those rows were never committed. One test each.

  4. 8604e80 The record, data and four ledger rows (ablation, a_priori, diagnostic_only, with R series).

Result (1.20% taker plus per-product slippage, in R, common window)
ArmNWin %E[R]Δ vs off, 95% CI (cluster bootstrap by entry day)
off20226.7%+0.270—
above18125.4%+0.218[−0.641, +0.540]
slope12129.8%+0.419[−0.515, +0.829]
both12128.9%+0.409[−0.524, +0.824]

Pre-registered verdict: no arm is promising. Every arm clears the 100-trade floor, and no lower bound is above zero, so nothing goes to walk-forward.

  • slope and both point the hypothesised way, but inside an interval about 1.3 R wide.
  • above barely binds and does slightly worse.
  • The largest effect in the data is the excluded first ~205 days (the 2022 bear-market onset): 18 trades averaging about −1.0 R.
  • The baseline itself remains descriptive under ADR 0006: +5 win-rate points over break-even against a 14-point detectable edge.
Honesty notes
  • Edits since the pre-registration: the driver changed only in how figures are stored (numeric strings; words moved to params). git diff 696bc5b -- docs/experiments/2026-09-27-turtle-sma200-filter.py shows every line.
  • Reproducibility: the run is deterministic (seed 830), and the committed figures equal the first run’s.
  • Prediction miss: 90% of entries were retained for above, not the 40–60% I predicted.
Verification
  • Full suite: 6785 passed, 3 skipped.
  • mypy, ruff check and ruff format --check are clean.
  • verify_chain on the tracked ledger: 98 rows, no problems.

research: accumulation policy after fees — no arm beats static DCA (#831) (#835)

Closes #831. Implements the approved design (#834, revision 2).

Three commits, in the order that matters:

  1. 7a1e900 feat(sim): keel/sim/accumulation_policy.py. Four allocators; an account with allowance-aware fees and average-cost lots; a joint stationary bootstrap; the D6 decision rule.
    • 47 tests, each seen failing first. 15 of 15 mutants killed; a 16th was equivalent, and its redundant code was removed.
    • Arm A reproduces dca_into_allowlist to the cent under zero fees on gapless candles. It fills at the next open where the benchmark fills at the close, as the design requires, and a second test pins that difference.
  2. 4113ed0 The pre-registration. The driver’s docstring is the design’s §3–§8, frozen. It was pushed at 02:04 UTC before the run. git diff 4113ed0 -- docs/experiments/2026-09-27-accumulation-policy.py is empty: the driver hasn’t changed since.
  3. bd18cae The record, data and 16 ledger rows (ablation, a_priori, diagnostic_only; chain verified, 114 rows).
Result (headline: 5 years without PAXG, allowance-aware fees, $30,500 deposited)
ArmTerminalSortinoMax DDP(ΔSortino > 0), 20 / 60-day blocksVerdict
A static DCA$46,6290.52379.5%—baseline
B value averaging$48,8400.54278.5%0.583 / 0.599not better
C steered deposits$47,9670.56479.1%0.365 / 0.407not better
D selective trimming$52,9190.61179.3%0.363 / 0.434not better

No arm is better than static DCA after fees, in either run or fee model.

  • Trimming (D) wins the historical path by 13.5% but improves Sortino in only 36–43% of 2,000 resampled paths. That’s a path-specific result, and the record declines to call it a win.
  • Value averaging (B) is the most consistent arm: 58–61% of paths, and about 2 points less drawdown. It is still far from the 0.95 bar.
  • Prediction misses are recorded. D was expected to do best on the with-PAXG run and was instead the weakest alternative there. PAXG’s slippage is about 100 bp per leg.
Verification
  • Full suite: 6832 passed, 3 skipped.
  • mypy and ruff are clean.
  • verify_chain on the ledger: no problems.
  • The record’s computed claims were re-checked against the data. Three were corrected before committing, and a causal story I hadn’t tested was removed.

Fixes

fix(sim): two rules of one kind on one asset get a row each (#829) (#832)

Closes #829.

What was wrong

report.edge_table and report.accumulation_table (#825) keyed each row "{rule.name}:{asset}". When two rules of the same kind sit on the same asset, the later rule’s row overwrote the earlier one’s.

  • On the deployment: ~/keel/keel.db has two paper BTC DCA rules, rule 19 at $50/week and rule 20 at $5/week. keel simulate printed a single row, the $5 rule’s, which looked like a ~$5/week DCA. The $50 sleeve was invisible.
  • A second, quieter effect: group_trades_by_class looked both rules up by that shared key. G2’s per-class pool counted the surviving rule’s trades twice and the other rule’s not at all. The test below shows a class pool of 0 trades where the pooled row has 1. The pooled row itself was always right, because it extends every rule’s trades before keying.
The fix

One function, report.rule_keys(rules), now keys all three tables:

  • A key that is already unique is unchanged ("turtle_breakout:BTC"), so existing readers and tests are untouched.
  • Rules that share a key are told apart by their rules.id ("dca#19:BTC", "dca#20:BTC"), or by position when a hand-built rule has no id. No row is ever lost.
  • All three tables must be given the same rules list, which simulate already does. The docstrings say so.
Verification
  • tests/sim/test_rule_keys.py has 6 tests. Five failed first on the old code; the “unique key unchanged” test is a regression pin that passes throughout. They cover:

    • two DCA rules, each at its own budget
    • two edge rules on one asset, with correct pooled N
    • the class pool counting each rule once (it was 0, now 1)
    • unique keys unchanged
    • colliding rules without ids
    • the rendered section showing both rows
  • Five mutants were each checked to have changed the source, and each was killed:

    MutantTests failed
    never disambiguate5
    class pool uses the old key1
    accumulation uses the old key2
    always disambiguate12
    ignore rule_id3
  • Real data, on a copy of the paper database over the 5-year window:

    RowBuysCostValue
    dca#19:BTC261$13,213.14 ($50.63/buy)$25,715.00
    dca#20:BTC261$1,321.31 ($5.06/buy)$2,571.50

    Before the fix only the second row printed.

  • Full suite: 6775 passed, 3 skipped. mypy, ruff check and ruff format --check are clean.

fix(rails): rail 14 is a monthly buy cap, not a fee-free allowance (#836) (#837)

Closes #836.

What was wrong

keel described rail 14’s cap as the venue’s fee-free allowance, and its research treated “inside the allowance” as a fee-free regime. keel’s orders go through Coinbase Advanced Trade, which has no fee-waiver tier:

  • On 2026-09-27 the account reported the Intro tier: 0.9% taker, 0.5% maker, has_promo_fee: false.
  • Every live fill had paid a fee: 1.19%, 1.14%, 1.19% and 0.89%.
  • Coinbase One’s “zero trading fees up to $500/month” applies on the main Coinbase platform, not Advanced Trade.
What changed (wording only; enforcement untouched)
  • keel/execution/guards.py has zero changed lines. The 29 rail 14 guard tests pass unchanged, which proves the cap, its fail-closed behaviour and every number it enforces are the same.
  • No schema rename. The free_volume_usd field keeps its historical name. A rename would need a migration and would change no behaviour.
  • Operator-facing text:
    • keel subscription set --help now says “Monthly BUY cap … not a fee waiver”.
    • The throughput report says “route WITHIN each venue’s attested buy cap … trades inside it still pay the venue’s fee”.
    • The significance verdict’s zero-fee phrase reads “a zero fee (hypothetical: Advanced Trade orders are never fee-free, #836)”.
    • The simulator’s tier section adds a paragraph saying its within-cap rows are hypothetical for keel’s orders.
  • Docs:
    • docs/rails/rail-14-subscription-allowance.md is retitled “the monthly buy cap”. Its “What it bounds” section no longer claims a fee waiver or a “profitability boundary”, and it carries a dated correction. The enforcement, veto and inspection sections are unchanged.
    • The runbook and the research toolkit are corrected in place.
  • Records are appended to, never rewritten. Five records that described a fee-free regime each gain a dated Fee note under the title: the 2026-08-21 significance, 2026-08-22 trailing exits and 2026-09-05 restatement records, the quant-lab note cross-verification, and the 2026-09-27 accumulation-policy record. The accumulation note says its flat-taker run is the one matching live, and the verdict is unchanged.
  • Code comments and docstrings now call the fee-free tier model hypothetical for keel’s orders.
Tests

tests/test_rail14_is_a_buy_cap.py has 6 tests, all seen failing first:

  • the help text,
  • the throughput report,
  • the significance label,
  • the tier section,
  • the rail 14 doc making no fee-waiver claim,
  • every record that describes a fee-free regime carrying the #836 note.

One existing test that pinned the old “fee-free” wording is rewritten to pin the corrected label.

Not in scope, deliberately
  • The keel doctor fee-rate readout (#836 item 4, marked optional there) is left for a follow-up.
  • Switching the DCA to limit orders was decided against on 2026-09-27.
Verification
  • Full suite: 6838 passed, 3 skipped.
  • mypy, ruff check and ruff format --check are clean.

fix(rails): exempt DCA buys from the total-exposure rail; rail 14 bounds DCA (#841) (#842)

Closes #841

The decision (the operator’s, 2026-09-27, final)

DCA is bounded by the venue plan’s cap (rail 14, the attested monthly buy cap), not by caps.max_exposure_usd.

  • Exempt DCA BUYs from the total-exposure rail (total_exposure_cap) only.
  • Per-asset concentration (per_asset_concentration_cap) still applies to DCA.
  • Rail 14 (monthly_subscription_allowance) still applies to DCA, unchanged. It is now the binding DCA limit.
  • Every other rail’s treatment of DCA is unchanged.

The reason: the live account accumulates through 7 DCA rules. About $213 is held against the $400 max_exposure_usd cap, so rail 4 would veto the sleeve after about one buy on Oct 9.

Which rails bind DCA now
RailRule-trading BUYDCA BUY
4: total_exposure_capbindsexempt (this PR)
6: per_asset_concentration_capbindsbinds
14: monthly_subscription_allowancebindsbinds. It is the limit that bounds DCA.
8: no averaging into losersbindsexempt (unchanged)
11: drawdown breakerbindsexempt (unchanged)
16: consecutive-loss breakerbindsexempt (unchanged)
every other railbindsbinds (unchanged)
  • DCA holdings still count in the total exposure that a rule-trading entry sees, so a growing DCA sleeve shrinks the room left for rule trades.
  • Rail 6’s per-asset limit is still max_per_asset_pct × max_exposure_usd. max_exposure_usd still bounds DCA per asset, but no longer in total.
  • SELLs are unaffected. Rail 4 never read a SELL.
What changed
  • keel/execution/guards.py: rail 4 now gates on is_buy and not intent.is_dca. I updated the module docstring’s “DCA exemptions” paragraph, rail 4’s comment and rail 14’s comment. The docstring now also names rail 16, which was already exempt but was missing from that paragraph.
  • keel/sim/account.py: SimAccount.can_open applies the same exemption, so keel simulate models what live does. The sim’s DCA path (portfolio_sim._process_dca_signals) goes through can_open, which vetoes and does not clamp. Before this PR it vetoed DCA over max_exposure_usd; now it does not. max_affordable_notional is unchanged because only the rule-slot path calls it.
  • docs/rails/rail-4-total-exposure.md is new and holds the exemption table. docs/rails/rail-14-subscription-allowance.md now says rail 14 is what bounds DCA.
  • The caps: comment in keel/templates/config.yaml and config.live.yaml says the same. The repo-root config.yaml is re-synced with its template, as test_the_template_stays_in_sync_with_the_repo_config requires.
  • keel doctor has no text that lists rails or their DCA exemptions, so nothing changed there.
  • executor.py is not touched.

This reaches live only after a release and deploy. Until then, the deployed rail 4 still vetoes DCA over max_exposure_usd.

Tests (written first; each was seen to fail for the stated reason, or is a pin)
TestBefore the fix
(a) test_rail4_dca_buy_over_max_exposure_is_not_vetoed_by_total_exposureFAIL: total_exposure_cap: open exposure 960.0 + 45 = 1005.0 exceeds max_exposure_usd 1000
(b) test_rail4_the_same_buy_without_is_dca_is_still_vetoed_by_total_exposurepass (regression pin; kills M3)
(c) test_rail6_dca_buy_over_the_per_asset_cap_is_still_vetoed_by_concentrationFAIL: extra total_exposure_cap beside per_asset_concentration_cap
(d) test_rail14_dca_buy_over_the_monthly_cap_is_still_vetoed_when_exposure_is_also_overFAIL: extra total_exposure_cap beside monthly_subscription_allowance
(e) test_a_dca_buy_over_max_exposure_is_not_vetoed_by_the_exposure_cap (sim)FAIL: total_exposure_cap … 1005 exceeds … 1000
(e) test_a_dca_buy_over_the_per_asset_cap_is_vetoed_by_concentration_only (sim)FAIL: total_exposure_cap listed before per_asset_concentration_cap
(e) test_a_rule_buy_over_max_exposure_is_still_vetoed_by_the_exposure_cap (sim)pass (regression pin; kills M6)
(e) test_parity_with_guards_check_when_total_exposure_is_the_tightest_capFAIL: sim vetoed a 100 DCA buy that the fixed guards allow
(f) test_rail4_never_gates_a_sell_whatever_the_order_class[False/True]pass (pin)

Two existing sim tests, test_exposure_cap_rejects_over_cap and test_close_frees_up_exposure_for_a_subsequent_open, probed the exposure cap with the sim helper’s default is_dca=True intent. They now probe with a rule-trading intent, which is what they test.

Mutants (each proven applied: the source differed from the saved copy; restored from that copy, never git checkout)
MutantResult
M1: drop the guards exemptionkilled by (a), (c), (d) and the parity test
M2: exempt DCA from rail 6 instead of rail 4killed by (a), (c), (d) and the parity test
M3: exempt non-DCA buys too (guards)killed by (b), test_rail4_total_exposure_cap_rejects_over_cap and test_a_malformed_history_row_still_counts_toward_the_exposure_cap
M4: remove the sim exemptionkilled by both (e) DCA tests and the parity test
M5: sim exempts DCA from concentration insteadkilled by 5 sim tests
M6: sim exempts rule entries tookilled by the (e) rule pin and 2 existing sim exposure tests
Checks
  • uv run pytest -q: 6850 passed, 3 skipped, exit 0
  • uv run ruff check keel tests: exit 0
  • uv run ruff format --check keel tests: exit 0
  • uv run mypy: no issues in 473 files, exit 0

fix(executor): size each DCA buy from the rule’s own amount (#840) (#843)

Closes #840

Milestone: DCA plan — proposal CLI and read-only card

What changed

keel/execution/executor.py _build_intent now sizes a DCA ENTER from the rule’s amount, setup.context["size_usd"] (what Dca.detect computes: budget_usd × (1 + dip bonus)), instead of the config’s single dca.budget_usd.

  • A new helper, _dca_budget(context, fallback), returns (amount, "rule" | "config"). It falls back to config.dca.budget_usd only when size_usd is absent or is not a positive, finite number. A bool is refused too. Decimal('Infinity') > 0 is true, which is why the finite check is there: commands/rules.py documents how an infinite budget_usd gets through.
  • The source is logged as executor.dca_sized, at INFO for rule and WARNING for config, with product, rule, rule_id and amount.
  • Paper: agent._paper_enter sizes through the same executor._build_intent and fills intent.qty, so paper is fixed by the same change. A test pins it. The account sim (sim/portfolio_sim.py) already preferred size_usd, so live, paper and the sim now agree.
  • No rail, cap, guard or veto changed, and max_exposure_usd is untouched. intent.notional is still spend(qty, entry) from the new qty, so rail 14 and every other guard see the real order, which is now smaller.
  • Docs: the executor’s “CAREFUL” comment is rewritten. It records that the old “config is the operator-facing dial” rationale was reversed by the operator on 2026-09-27. Also updated:
    • the executor module docstring and the _order_spec docstring;
    • a new DcaConfig docstring (in packages/keel-core);
    • the dca: comment in config.yaml, keel/templates/config.yaml and keel/templates/config.live.yaml;
    • equity.sizing_equity’s docstring;
    • docs/operator-runbook.md and docs/RELEASING.md.
Before and after
ruleassetamountcadencebuys/month (30.44 d)before ($50 each)after (rule’s amount)
6BTC$407d4.35$217.4$173.9
8ETH$257d4.35$217.4$108.7
9PAXG$2514d2.17$108.7$54.4
10–13ADA/XLM/DOGE/FET$15 each14d2.17 each$434.9 (4 × 108.7)$130.5 (4 × 32.6)
≈ $978≈ $467

Before, the book tried to spend about $978 a month against rail 14’s $500 cap, so rail 14 vetoed the tail of each month. After, it spends the rules’ ~$467, which fits under the cap. Rail 14 is unchanged and still vetoes any buy that would cross it.

This changes live behaviour only after a release and deploy. The live deployment runs installed wheels, not this repo.

Tests (each one seen failing before the implementation)
testwhat it pinsred before the fix
(a) test_live_dca_sizes_from_the_rules_size_usd_not_the_config_budgetthrough the real execute() path, size_usd=25 at 50,000 places qty 0.0005 with quote_size 25, and logs source=rule0.001 != 0.0005
(b) test_live_dca_falls_back_to_the_config_budget_without_a_usable_size_usd ×8absent, None, 0, −25, Infinity, NaN, "25" and True all size from the config’s 50 and log source=configno executor.dca_sized record (fallback sizing already held; recording the source is new)
(c) test_a_dca_rules_detect_feeds_the_live_order_size_end_to_endDca(budget_usd=15).detect(...) → execute() → qty 0.0005 at 30,0000.001666… != 0.0005
(c′) test_a_dip_scaled_dca_buy_spends_size_usd_not_the_rules_base_budgetwith a dip, size_usd 18.75 ≠ budget_usd 15, and the order spends 18.75added after the fix; red against the old code via mutant M1 below
(d) test_paper_dca_fill_is_sized_from_the_rules_amount_not_the_config_budget_paper_enter with a real Dca(budget_usd=15) fills 0.5 at 301.666… != 0.5
(e) test_rail_14_sees_the_rules_true_notional_and_admits_a_25_dollar_buy_that_fits$460 spent + $25 = $485 against a $485 cap is placedintent notional 50.000 != 25
(e) test_rail_14_still_vetoes_a_25_dollar_dca_buy_that_would_cross_the_cap$460 + $25 = $485 against a $480 cap is vetoed, and the veto names 460 + 25 = 485the veto named 50.000
Mutants (each proven applied by a changed sha256, restored from a saved copy, never git checkout)
mutantresultkilled by
M1: revert to config.dca.budget_usdKILLED (6 fail)(a), (c), (c′), (d), both (e)
M2: drop the fallback (use size_usd unconditionally)KILLED (10 fail)all 8 (b) cases plus 2 existing DCA tests
M3: read budget_usd instead of size_usdKILLED (4 fail)(a), (c′), both (e)
M4: accept size_usd <= 0 (< 0)KILLED (1 fail)(b)[zero]
M5: drop the finite checkKILLED (2 fail)(b)[infinity], (b)[nan]
M6: drop the bool guardKILLED (1 fail)(b)[bool]
Checks
  • uv run pytest -q: 6854 passed, 3 skipped, exit 0
  • uv run ruff check keel tests: exit 0
  • uv run ruff format --check keel tests: exit 0
  • uv run mypy (bare): no issues in 473 source files, exit 0
Not done here
  • deploy/live-rules.json still records a single BTC DCA rule at budget_usd: "50". The brief lists seven live DCA rules, with BTC at $40. Now that the rule’s amount is what spends, that manifest is worth syncing from the deployment in a separate change.
  • The sim’s own fallback (size_usd or config.dca.budget_usd) still accepts a negative size_usd. It is not touched here, and Dca refuses a non-positive budget_usd at construction.
  • The root config.*.yaml profiles (config.live-sandbox.yaml, the paper profiles) keep their uncommented dca: blocks.

Research & validation

docs(spec): accumulation policy — value averaging and band rebalancing vs static DCA (#831) (#834)

Refs #831. A draft for review. No code and no simulation until it’s approved. Merging this PR is the approval; comments or edits here are the review.

docs/superpowers/specs/2026-09-27-accumulation-policy-design.md covers:

  • The formulas.
    • Bounded value averaging: ΔC = clamp(w·D·t − H, C_min·w·D, C_max·w·D), with a zero-growth target path, buy-only, and C_max = 3.
    • Contribution steering: the monthly deposit goes to underweight assets first; it never sells.
    • Band rebalancing: a ±15% band relative to each target weight, checked monthly, trimming and topping up back to target.
  • The account model every arm shares.
    • Deposits: identical $500 monthly deposits. Idle cash earns nothing, consistent with the fiqh constraints.
    • Fees: modelled the way this account is actually billed. Buys are fee-free up to the $500/month Basic allowance; sells always pay the 1.20% taker fee. A flat-taker sensitivity run sits beside it.
  • Harness requirements.
    • A new pure allocator module, separate from portfolio_sim.
    • Static DCA must reproduce dca_into_allowlist exactly, as the oracle for the whole harness.
    • Tests come first.
  • Rails. This phase is simulation only. The spec records what a live version would hit. Most notably, sell_only_on_rule requires a rule_kind on every sale, so rebalancing trims need a policy decision. The two buy-only arms avoid that question entirely.
  • The prior it has to answer. The knowledge base (§51) calls fixed-weight rebalancing the wrong paradigm here, because correlated crypto offers little dispersion to harvest. The spec sharpens the hypothesis: any rebalancing benefit should come through PAXG, the one low-correlation leg.

Revision 2 (from review): selective trimming in arm D, a ±1.5-point band floor for the small sleeves, and 20- and 60-day bootstrap blocks reported side by side, with the decision rule required to hold under both.

Decisions for you (§9)
#DecisionProposed
D1Universe and windowPAXG’s Coinbase history starts in May 2025, so: a 5-year PAXG-free primary run plus a 1.4-year with-PAXG secondary run
D2Monthly deposit$500
D3Fee modelallowance-aware, plus a flat-taker sensitivity run
D4Value averagingC_min 0, C_max 3, g = 0
D5Band and trimmingmax(0.15·w_i, 0.015), monthly; selective: trim only upper-band breaches, proceeds to underweights
D6Decision ruleP(ΔSortino > 0) ≥ 0.95 and median Δmax-drawdown not worse, under both 20- and 60-day bootstrap blocks

Once D1–D6 are settled, §3–§8 become the driver’s pre-registered docstring, committed before any run, exactly as for #830.

Other changes

chore(release): 0.19.0 — DCA sized per rule, bounded by the venue plan’s cap (#845)

Version bump only: all 8 pyprojects, their pins, and uv.lock go from 0.18.0 to 0.19.0. Same shape as #828.

MINOR because live DCA sizing changes (#843) and a rail changes scope (#842). No schema change.

  • #843 (#840): each DCA buy is sized from its rule’s size_usd
  • #842 (#841): DCA buys are exempt from rail 4; rails 14 and 6 still bind them
  • #837 (#836): rail 14 relabelled as a monthly buy cap

Local: pytest 6882 passed / 3 skipped, ruff and mypy clean.

v0.18.0

Voir cette version sur GitHub ↗

Built from af9db817b3c8. Version binds to this hash: keel --version reports keel 0.18.0+af9db817b3c8 [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.18.0-py3-none-any.whl
keel versions

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

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

Configure

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

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

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

Fixes

fix(sim): DCA buys once per completed day, and the report shows its sleeve (#821) (#825)

What was wrong

keel simulate got the DCA rule wrong in four ways, and the report hid all of them.

  • The simulator bought DCA about 24 times per cadence day. It evaluates rules on every hourly bar. A DCA rule decides on daily candles, so its answer is the same on all 24 bars of a day, and each of those bars bought. On a synthetic 60-day repro, 9 cadence days at $50 spent about $10,768 instead of $450. Every earlier keel simulate run with a DCA rule has an oversized DCA sleeve. That inflates or distorts its ending value, return, drawdown, caps headroom and rail-14 trading volume.
  • It read the day before the day had happened. The simulator’s daily window includes the day still in progress, and that bar is stored with the completed day’s OHLC. From 00:00 UTC, Dca.detect priced its buy at that day’s close and measured the dip against that day’s high. Live only has closed candles, so live DCA sees yesterday, and the simulator’s cadence day was one day ahead of live. TurtleBreakout already guarded against this with _completed_days; Dca had no guard.
  • The edge table showed DCA as N 0. Dca declares no timeframe, so backtest() gave it only hourly candles. It needs daily candles, so it never fired. Declaring a daily timeframe would not have fixed it. Its target=entry / stop=0 sentinels would turn each buy into a fee-only round trip closed on its fill bar, which is a fake loss and not an edge.
  • The sleeve was bought but never shown. DCA lots are never closed by design. per_asset_pnl and trade_count come from closed trades only, so the sleeve was inside ending_value and appeared nowhere else in the Markdown report or the HTML artifact.
What changed
  • Completed days only. The forming-day guard now lives in keel/strategy/rules/base.py as completed_days, with its docstring. TurtleBreakout imports it under its old private name, so every existing reference still resolves. Dca.detect now uses it. Live behaviour is unchanged: when the last daily bar is closed and the hourly series is past it, that bar is kept. A test pins this.
  • One DCA decision per UTC day, per (asset, rule) in portfolio_sim. The day is marked when the decision is taken, whether the buy fills or is vetoed. That matches live, where the agent trades once per UTC day. Each buy is logged in SimResult.dca_buys (decision time, fill time, qty, notional, and cash cost including fees). SimResult.final_prices records each asset’s last close.
  • Edge table. Rules with the new Rule.accumulates flag, which today means Dca, are left out of edge_table. So they are not in __pooled__ or group_trades_by_class, and G2 gets neither fake round trips nor a zero-trade sample. The new report.accumulation_table runs them over daily candles, using the same completed-day and once-per-day rules. It reports buys, qty, cost basis including fees, last close, marked value and unrealized P&L. The report renders it as its own section, “DCA accumulation (not round trips)”, right after the edge table. The edge table’s own columns are untouched, so #820’s R-multiple change should merge cleanly.
  • Account report. build_account_metrics now returns dca_sleeve, built by portfolio_sim.dca_sleeve: per asset, the buys, qty, cost basis, value at the last close, and unrealized P&L. The Markdown report shows it under “DCA sleeve (accumulation, marked to market)”. The per-asset list is now labelled “Per-asset realized rule P&L (closed round trips; the DCA sleeve is below)”. The HTML artifact gets the same sleeve table, and its chart is retitled “Per-asset realized rule P&L”.
  • keel rules promote: nothing changes. Its docstring says a DCA rule produces no backtest trades, so --force is its only path to paper. That is still true. backtest() is unchanged, a test pins N 0, and the promotion path never reads the accumulation row.
Before and after (synthetic repro: 60 days of flat-ish hourly and daily candles, cadence 7, $50)
BeforeAfter
Account-sim DCA buys~24 per cadence day8, one per cadence day
Account-sim DCA spend~$10,768$400.00 notional ($405.95 cash with taker fees and slippage)
Day the buy is decided onthe cadence day itself, reading its close from 00:00the day after the cadence day closes, as live
Edge tabledca:BTC row with N 0no round-trip row; accumulation row with 8 buys, $400.90 cost, $428.93 value, +$28.03 unrealized
Account reportsleeve absent; per_asset_pnl {}DCA sleeve table: 8 buys, 3.7572 qty, $405.95 cost, $429.79 value, +$23.84 unrealized

The window has 9 cadence days, but the ninth is its last day. That day’s decision falls on the next day, outside the window, so the result is 8 buys, not 9.

Verification
  • TDD: each new test was written first and seen failing for the right reason. The sim test got qty 96, which is 0.5 × 8 cadence days × 24; the forming-day tests got a buy priced off the forming day; the edge test found a dca:BTC round-trip key; the other tests failed on missing names or keys.
    • tests/sim/test_dca_sleeve.py, all using the real Dca: (a) total spend equals budget × cadence days; (d) accumulation row plus a pooled result byte-identical with or without DCA, and group_trades_by_class is empty; (e) sleeve value equals qty × last close and unrealized equals value − cost; (f) the Markdown tables are parsed row by row. Also: the realized-P&L label, and N 0 from backtest().
    • tests/strategy/test_dca.py: (b) at hour 0 the forming day’s close and high are ignored, and a forming cadence day does not fire; (c) live-shaped input still fires on the closed day. (c) is a regression pin, so it passes both before and after the change.
    • tests/sim/test_artifact.py: the sleeve table header and cells, and no table when there is no sleeve.
  • Mutation run: each mutant was applied alone, the source was checked to confirm it changed, and the file was restored from a saved copy. All five were killed:
    • once-per-day guard removed (3 tests failed)
    • completed-day drop removed from Dca (5 failed)
    • DCA routed back into edge_table and the pool (2 failed)
    • sleeve marked at cost instead of the last close (1 failed)
    • completed_days drops the last day whenever hourly data exists, the live regression (1 failed)
  • uv run pytest -q: 6735 passed, 3 skipped. uv run ruff check keel tests is clean, uv run ruff format --check keel tests is clean, and uv run mypy reports no issues. No baseline fixtures changed.

Closes #821

fix(research): the edge table, G2 and promotion judge R, not price units (#820) (#826)

What was wrong

The simulator’s edge table said its numbers were “unit-less R-multiples”. They weren’t. Every trade already stored the correct R (Trade.r_multiple), but stats.summarize never used it. Expectancy, average win and loss, profit factor, max drawdown and MFE/MAE were all built from pnl, which is price units for a one-coin position. A BTC trade and an XLM trade differ by five orders of magnitude in price, so:

  • The pooled row was decided by BTC. One BTC trade that lost 1R (−2000) outweighed any number of XLM trades that won 2R (+0.004 each).
  • G2 in keel simulate, and #338’s pooled promotion, read those same numbers. check_floors, _realized_rr, pool_stats and _pooled_floors all worked in price units.
  • Two more defects sat on the same path:
    • The R denominator was signed, (entry_fill − stop) × qty. When the fill gapped below the stop, the risk went negative and a losing trade got a positive R. This happened in all three close paths: backtest, paper and portfolio_sim.
    • The pooled max drawdown followed the trades in rule order (rule A’s whole history, then rule B’s). That sequence never happened.
What changed
  • Trades carry their risk. Trade.initial_risk and SimTrade.initial_risk hold the per-unit |entry_fill − setup.stop|: the fill the trade actually got, against the setup’s ORIGINAL stop, never a trailed or break-even stop. Both fields are last and default to None, so existing constructors keep working.

  • One R formula. rules.base.r_multiple_of(pnl, initial_risk, qty) = pnl / (initial_risk × qty). It returns None when the risk is missing or zero. All three close paths use it, so R’s sign now always matches the P&L’s.

  • R versions of the stats, next to the money ones. BacktestResult gains:

    • expectancy_r, avg_win_r, avg_loss_r, profit_factor_r
    • max_drawdown_r: the drawdown of cumulative R
    • avg_mfe_r, avg_mae_r
    • n_excluded_no_risk
    • n_wins_r / n_losses_r, which let pool_stats pool R exactly

    A trade with no risk is left out of the R stats and counted, never treated as 0R. A sample with no R at all gives None for every R field.

    The money fields are unchanged. insights, the web payload, tuning and walk-forward still read them.

  • Edge table. The pooled row and the per-class pools (group_trades_by_class) are sorted by exit time before they are summarised. The table shows R columns under a label that is now true, and adds an “Excluded from R” line when any trades were left out.

  • Promotion.

    • check_floors, _realized_rr, pool_stats/_pooled_floors, and should_demote (whose docstring says it mirrors check_floors) now judge expectancy and R:R in R.
    • No R at all means a refusal, not a pass. For demotion, no R demotes the rule, following transition’s rule that missing evidence must never block pulling a rule back from real money.
    • min_trades and min_win_rate are unchanged. min_expectancy is now an R threshold; every shipped config has it at 0.0.
  • Paper history. paper.track_record recovers initial_risk for exits written before this change from the entry payload’s fill and stop. It then recomputes their R instead of trusting a value the old signed formula stored. New exits record initial_risk in the payload.

  • keel rules backtest now prints expectancy_r and max_drawdown_r first, then expectancy_px and max_drawdown_px with a legend “(px = price units, 1-coin notional)”. Before, it printed bare expectancy= and max_drawdown=.

Behaviour changes to know about
  • G2 and pooled promotion can change verdict. A multi-asset pool whose sign in price units was set by BTC is now judged by its R. It can flip either way: a pool whose BTC trades lost R while cheaper assets won R now passes, and the reverse now fails. Refusal reasons now read expectancy_r … (in R), rr_r … (in R), or no R: … refusing rather than passing.
  • A single-asset rule can also move. R:R was a ratio of average pnl and is now a ratio of average R, and the two differ whenever risk per trade varies.
  • insights’ gate distance still displays money expectancy and R:R, as it should. Its pass/block verdict comes from check_floors, which now judges R, so the blocking reasons name R.
Test fixtures that changed
  • The hand-built BacktestResult fixtures in test_promotion._stats, test_report._pooled_result and test_cli’s pooled fake_backtest now also fill the R fields with the same numbers. Without R, the floors correctly refuse them.
  • test_journal_report_enriches_r_multiple_from_paper_track_record stored r_multiple="2.5" on a trade whose own pnl and stop gave 1.0R. track_record now recomputes R from the journalled fill and stop, so the fixture was made self-consistent (pnl 25 on a risk of 10).
  • Baseline: tests/fixtures/baseline_backtest.json and tests/baseline/serialize.py are unchanged, and the baseline test passes. The baseline has no gap-below-stop entries, so every pinned r_multiple is the same value, and the serializer doesn’t emit the new fields.
Verification
  • TDD. 34 new tests. Each was run and seen failing before its implementation. Examples of the failure reasons:

    • The backtest, paper and portfolio_sim gap tests failed on the sign: 0.45 != -0.45 and 1.28… != −….
    • The chronological-pool test failed on the order: [18000, 10800, 25200] != [10800, 18000, 25200].
    • The rest failed on the missing fields and helper.

    One exception: test_g2_refuses_a_pooled_sample_with_no_r was written after the implementation. It fails on the old code, where G2 passed on price units, and a mutant kills it (M7 below).

  • Mutation. Each mutant was applied alone, checked to have changed the source, run, and restored from a saved copy. All 10 were killed:

    MutantKilled by
    M1 signed risktest_the_shared_r_helper; also, separately, the backtest, paper (×2) and portfolio_sim gap tests
    M2a summarize reads pnl instead of Rtest_r_aggregates_are_identical_across_a_btc_scale_and_an_xlm_scale_sample
    M2b check_floors expectancy in price unitstest_check_floors_judges_expectancy_and_rr_in_r
    M2c _realized_rr in price unitstest_check_floors_judges_expectancy_and_rr_in_r
    M3 no-risk trades counted as 0R (no exclusion)test_trades_with_no_or_zero_risk_are_excluded_from_r_and_counted
    M4 no sort by exit timetest_edge_table_pools_in_exit_time_order_so_max_drawdown_is_chronological
    M5 risk measured from the managed stoptest_a_managed_stop_still_measures_r_against_the_original_stop
    M6 pool_stats doesn’t pool Rtest_pooled_pass_when_the_rule_alone_is_short_but_the_parameter_set_is_not
    M7 no R passes the floorstest_g2_refuses_a_pooled_sample_with_no_r
    M8 paper legacy exit trusts the stored signed Rtest_track_record_recovers_initial_risk_for_a_legacy_exit_from_its_entry
  • Full suite: uv run pytest -q gives 6755 passed, 3 skipped, including the 34 new tests.

  • Lint and types: uv run ruff check keel tests passes. uv run ruff format --check keel tests finds all 426 files formatted. Bare uv run mypy reports no issues in 468 source files.

Closes #820

Compliance & rails

chore(live): per-asset ceiling 0.50 -> 0.75 ($200 -> $300) (#819)

Mirrors a change already live in ~/keel/config.live-sandbox.yaml (applied 2026-09-26 at 01:16 UTC, before that day’s 01:20 run), so the tracked copy stays byte-identical to the deployment. Before this edit the two files were identical.

Why
  • The weekly BTC DCA was vetoed on 2026-09-25 by per_asset_concentration_cap. BTC held $151.81 at cost, from three $50 buys. The fourth buy projected to $201.81, over the 0.50 × $400 = $200 limit.
  • Exposure is measured at cost, so a falling price never frees room. At 0.50 the DCA was permanently capped at three buys, regardless of the ~$257 in available cash.
  • At 0.75 the limit is $300, which admits three more weekly buys. The first projection over it is $301.81.
Unchanged
  • max_exposure_usd stays at 400. It’s also the sizing proxy, and the config’s own note says to raise it only alongside rail 14.
  • The total exposure after the next buy is about $263.51 (BTC $151.81, PAXG about $61.70 at cost, plus $50), still under $400.

A dated note was added to the caps history comment.

Research & validation

docs(adr): 0006 — daily strategies are evaluated on the pooled sample, never per asset (#822) (#824)

Closes #822.

Adds docs/decisions/0006-pooled-evaluation-of-daily-strategies.md and its index entry.

  • Records the owner’s decision of 2026-09-26 on the floor #427 left open: n ≥ 100 pooled.
  • Formalizes #338’s pooled unit of evaluation, including its diversity floor of 5 products × 10 trades.
  • Per-product statistics become diagnostics only.
  • Evidence that per-asset n ≥ 100 is unreachable: a 5-year backtest gives the daily Turtle 5 to 18 trades per product, and 220 pooled.
  • Stated as a consequence, not hidden: at n = 100 pooled, n_eff ≈ 39, so any evaluation at the floor is descriptive and can only see an edge of about 20 points or more. A confirmatory claim still needs about 259 pooled.
  • Pooled expectancy must be unit-free, which ties this record to #820.

No code or config changes: min_trades: 100 keeps its value, and this record states its unit is the pool.

docs(experiments): the hourly turtle over five years — 4,871 trades, 0 of 19 positive at the fee paid (#823) (#827)

Refs #823 (closed with its result). Records the run with its complete per-trade dataset and the fee-to-risk arithmetic behind it.

Files
  • docs/experiments/2026-09-26-hourly-turtle-5year-backtest.md: the record.
  • …-5year-backtest.py: the driver. It uses the same code path as keel simulate’s edge pass (report.edge_table, simulate.load_sim_candles, per-product slippage), reads the database read-only, and replays the exact window of the original run.
  • …-5year-backtest.jsonl: all 4,871 trades, each with its R at the 1.20%, 0.60% and 0% fee levels, plus per-product and pooled summaries. It’s 1.5 MB, the largest file in docs/experiments/. The README says bulk sweep output isn’t committed; this is the run’s recorded output rather than a sweep, and it was asked for as the complete dataset. Happy to drop it to summaries only if you’d rather not carry the size.
  • docs/experiments/README.md: the index entry.
Findings
  • The driver reproduces the #823 run exactly: N = 4,871, −1.178 R at 1.20% and −0.696 R at 0.60%. It asserts that the trade set is identical across fee levels.
  • 0 of 19 products are positive at 1.20% or 0.60%. At a measured 0% fee, only BTC, ETH and SOL are, the three products with 5–11 bp slippage. Pooled at 0% it’s −0.215 R.
  • Mechanism: the 2×ATR hourly stop sits a median 2.75% from entry, so a 2.40% round trip costs about 0.87 R per trade, matching the measured 0%→1.20% difference. The daily stop sits a median 11.6% away, so the same round trip costs about 0.21 R there.
  • Relationship to earlier records: consistent with 2026-08-11. It reconciles 2026-08-12’s “profitable at zero fee” (priced at a flat 5 bp) with per-product slippage.
Honesty notes
  • Not pre-registered: it records a run already made.
  • Corrects an earlier figure. My #823 comment’s zero-fee number (about −0.2 R) was extrapolated; the record replaces it with the measured −0.215 R.
  • States what it doesn’t show. Daily strategies have no demonstrated edge either. “Regime-filtered daily strategies” is a hypothesis this record motivates, not one it tests. Weekly DCA is accumulation, not an edge claim.
  • Records the decommissioning of the hourly paper account (2026-09-26): both launchd jobs unloaded, 19 rules disabled, one paper BCH position frozen.
Verification
  • Full suite: 6769 passed, 3 skipped.
  • tests/test_experiment_cost_pointers.py passes: the record points at the per-product restatement.
  • ruff is clean on the driver.

Other changes

chore(release): 0.18.0 — evidence in R, DCA simulated once a day, pooled evaluation as policy (#828)

Version bump for 0.18.0: the eight workspace versions, the inter-package == pins, and uv.lock. Same shape as 0.17.0 (#818): 9 files, 25 lines in and 25 out. uv lock --check passes.

Why a minor bump: #826 changes what promotion decides. G2 and #338’s pooled path now judge in R, min_expectancy is an R threshold, and a sample with no R is refused. #825 adds new report sections. No schema change since v0.17.0 (keel/data/ is untouched).

What’s in it
  • #826 (#820): the backtest edge table, G2 and promotion work in R rather than price units. A losing trade that gapped below its stop now gets a negative R, and pooled rows are in exit-time order.
  • #825 (#821): keel simulate buys DCA once per completed day (it bought 24 times per cadence day), with no forming-day look-ahead, and reports the DCA holdings at market value.
  • #824 (#822): ADR 0006, daily strategies are evaluated on the pooled sample.
  • #827 (#823): experiment record for the hourly Turtle, 4,871 trades.
  • #819: the live per-asset ceiling config mirrored into the repo (0.50 → 0.75).

Live trading is unaffected. These changes touch offline tooling and promotion only. Replaying 14 days of real live candles through the new DCA code gave the same buy days as before.

After merge
  1. Run Actions → Release with version 0.18.0.
  2. Install into ~/keel using the runbook’s steps: back up each database, install the five wheels, run keel migrate (nothing to apply), then keel versions (must show [release]) and keel status.
  3. Restart the three running keel serve consoles. The hourly console is decommissioned.

v0.17.0

Voir cette version sur GitHub ↗

Built from f9b45962408d. Version binds to this hash: keel --version reports keel 0.17.0+f9b45962408d [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.17.0-py3-none-any.whl
keel versions

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

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

Configure

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

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

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

Fixes

fix(agent): the cycle line counts rail vetoes and names the rail (#812) (#813)

Closes #812.

What changed

render_loop_result in keel/commands/trading.py now ends each cycle line with a vetoed=N token:

before: ... signals=3 blocked=0 entered=0 exited=0
after:  ... signals=3 blocked=0 entered=0 exited=0 vetoed=3 (account_dd_breaker_weekly)
  • N counts entries that were evaluated, not placed, and have a non-empty vetoed_by. It is one per entry, not one per rail (the same one-per-entry rule activity.summarise_cycle uses).
  • The overlay’s blocked still differs from this line’s. For one cycle, overlay blocked = line blocked + vetoed + unplaced entries no rail named. Both docstrings now say so; the old summarise_cycle claim that its counts were the line’s is corrected.
  • Rail names come from one parse. The new guards.rail_name replaces three copies: activity._first_clause, doctor’s inline split, and this token’s.
  • The rail list names each distinct rail once, in first-seen order, using the leading clause of each violation string. The drawdown arithmetic stays in the JSON log. Bare routing-gate tokens such as max_entry_spread are passed through as-is.
  • When nothing was vetoed the line prints vetoed=0 with no parentheses, so a missing token never has to be read as zero.
  • Existing tokens keep their meaning and order. keel-live-run.sh greps only signals=[0-9]+.
Out of scope
  • vetoed= counts entries only. A rail-vetoed exit (for example base_balance refusing a sell) still prints exited=0 with no reason. None has happened in any log yet.
  • An unplaced entry with an empty vetoed_by (a paper no-fill, a declined confirm) is still not shown on the line. It isn’t a rail veto, and adding it would mean choosing a separate token.
Verification
  • Wrote 4 tests first and confirmed they failed on the missing token. They compare the exact line or its exact tail, not substrings.
  • Mutants for “an empty vetoed_by counts as a veto”, “count per rail”, and “no dedupe” were each applied and each killed.
  • The cycle-line tests pair each entry result with its signal, as run_once does, so the FET test pins the real line, signals=3 … vetoed=3. guards.rail_name has its own parametrised test.
  • Full suite: 6684 passed, 3 skipped. ruff check, ruff format --check and mypy are clean.

fix(web): the console switcher carries no token and sits beside the chip (#815 review) (#816)

Follow-up to #815 (merged, not yet released), from its review. Refs #814.

What was wrong in #815
  1. Session tokens reached the page. live_peers returned url_for(record), which is http://host:port/?token=…, for every running console, and /api/config carried the list into <option value>. So the paper console’s page held the live console’s session token, and its own. That breaks security.py (“HttpOnly and must never be written into the page”). On a tunnelled deployment it would also have shipped every local token to the remote viewer.
  2. The dropdown was always empty. el(tag, className, text) has no children form, so el("select", …, ...options) set the select’s text to "[object HTMLOptionElement]" and dropped every option.
  3. A console with no record could name the wrong console. A console started from a terminal writes no record. It then got a select with no current entry, and once item 2 was fixed the browser would pre-select the first option, which is another console’s name.
  4. Each console’s mode was frozen at start-up. It went stale after a config edit, contradicting _auto_trade_mode’s rule of re-reading the config on every request.
  5. It turned the session chip into a dropdown, which #704 explicitly forbids. It only got past test_neither_the_chip_nor_the_banner_builds_anything_clickable because select isn’t on that test’s token list.
The fix
  • No token on the wire. payload.profile_switcher sends {switchable, choices: [{label, href}], display}. Each href is a token-free /switch/<port> on the current console.
  • GET /switch/<port>. This goes through normal admission, then a locality check, then reads the peer’s record at click time. It answers 303 to the peer’s own ?token= hand-off, which is the route keel open already uses. No page script can read a redirect’s Location header.
    • Refusals: no session gives 403; a remote deployment or non-loopback peer gives 403; an unknown, dead or malformed port gives 404; this console’s own port redirects to /.
  • One locality rule. security.local_deployment is split out of gated_action_permitted, so the switcher offer and the switch route can’t disagree about what “local” means.
  • Service worker. sw.js now sends /switch/ navigations to the network. Otherwise the cached shell would answer them and switching would silently do nothing.
  • A separate control. #console-switcher sits beside the chip. It’s a select with a disabled “switch console…” placeholder, listing the other consoles.
    • sessionChip is byte-identical to its pre-#815 code and passes #704’s rule unchanged.
    • The new control has its own rule: navigation only, with no button, link, form, input or handler of its own.
    • The deployment card gains a read-only “running consoles” row.
  • Smaller fixes.
    • This console is listed from its own arguments, never from a record.
    • Labels carry no mode, and records no longer store one.
    • serve now calls api._profile_name instead of an inline copy of it.
Verification
  • tests/web/test_profile_switcher.py is rewritten (23 tests). The old version asserted the token was present in the URL.
  • Six mutants were each killed: spreading children into el, dropping the locality check, removing the service-worker guard, listing this console as a choice, offering switching on a remote deployment, and an extra payload field.
  • Full suite: 6711 passed, 3 skipped. ruff check, ruff format --check and mypy are clean.
  • Manual check in Chromium. I ran two detached consoles in a throwaway KEEL_HOME (keel-live and keel-paper) with the service worker controlling the page.
    • The chip shows the profile as plain text, and the switcher lists the other console.
    • Switching paper → live → paper works both ways: each lands authenticated (/api/config 200) with no token left in the URL.
    • /api/config contains no token=.
  • Known limit: a console started after the page loaded appears on the next reload. Following a stale entry returns a 404 that asks you to reload.

Note: main currently has a JS syntax error from #810 (a duplicate const note in render.js), so every console built from main is blank. It’s fixed separately. For the manual check above I applied that rename locally; it is not in this PR.

fix(web): the refusal view declared note twice, so no page ran (#810 regression) (#817)

Problem

#810 (a1ee344) added const note = el("p") to refusedView in render.js, but that function already declares const note = el("p", "detail"). Declaring the same name twice in one scope is a SyntaxError for the whole module. render.js never loads, main.js imports it, and so every console built from current main shows a blank shell. The browser reports Identifier 'note' has already been declared.

It isn’t released (no tag contains a1ee344), and ~/keel runs 0.16.0, so nothing deployed is affected.

Fix
  • Renamed the first note to howNote.
  • Added a test: test_no_module_declares_a_name_twice_in_one_scope runs over every module in static/js/. It scans for a const/let/class name declared twice in one block scope, after _code_only strips strings and comments.
  • test_the_redeclaration_scan_can_fail pins that the scan catches the #810 shape and doesn’t flag the same name in sibling blocks or loop heads.
Why tests missed it

The repo deliberately has no JavaScript toolchain, so nothing ever parses the client. This scan covers the one class of parse error that got through. It is not a general parser.

Verification
  • The new test failed on render.js with ['note'] and passed on the other nine modules before the fix.
  • node --check parses the fixed render.js.
  • tests/web: 987 passed. ruff is clean.
  • I ran the same rename in a real browser while checking #816, and the shell loads.

Other changes

fix(execution): a cache record predating quote_increment is a MISS, not an “unknown” (#807)

#802 shipped inert on the deployment it was cut for. Found in production, 2026-09-17.

What happened
record fetched_at : 2026-09-16 01:20:01   (written by the pre-#802 build, no quote_increment key)
0.16.0 installed  : 2026-09-16 20:21
cycle now_ts      : 2026-09-17 01:20:03
age               : 24.0 h  against a 168 h TTL  ->  counted FRESH

=> _price_increment_for read the absent key as "unknown"
=> prices went out unrounded
=> executor.order_rejected  PAXG-USD SELL  'Too many decimals in order price'

The identical rejection as before the upgrade, on a build that contained the fix. The position stayed unprotected, and would have for up to seven days.

Clearing the row by hand healed it immediately — the venue reports the tick fine (PAXG-USD base=0.00001 quote=0.01) — and the rejection moved on to a different, legitimate one. But no deployment should need that.

The first cut called this correct

“A record written before #802 carries no quote_increment key and reads as unknown until it expires, which is correct: nothing knew the tick when it was written.”

Correct in the narrow sense. Operationally it waits out a TTL on data already known to be incomplete, and does so on precisely the machines that just upgraded to get the fix.

The obvious fix has a worse trap

“Refetch whenever the key is missing” would refetch every cycle, forever, for any product whose venue genuinely reports no tick — putting a venue round-trip back into the order-placement path this cache exists to keep out of. That is the latency get_instrument’s per-product shape was chosen to avoid.

So the writer now records quote_increment always, explicitly null when the venue has none:

record statemeaningaction
key absentwritten before anyone askedrefetch once
key present, nullthe venue was asked and has nonehonour it, don’t re-ask
key present, valueknownuse it
force_refresh, not a delete

_base_increment_for grew a keyword rather than having the caller delete the row first. Deleting is the shorter way to force a miss and the wrong one: a venue call that then failed would have discarded a perfectly good base_increment and put SELL sizes back on the wire unquantized, which is #513.

One test changed rather than being deleted

test_a_record_without_a_quote_increment_reads_unknown (from #804) asserted the old behaviour — it pinned the defect. It is now test_an_unparseable_quote_increment_reads_unknown, keeping the narrower claim that survives: a value present and unusable is unknown, and is not repaired by guessing the common tick. The docstring says why it changed.

Verification

Mutation, both halves independently:

  • reverting the read guard ("quote_increment" in cached) → 2 tests fail
  • writing the key only when non-null → the third fails
ruff check keel tests packages  -> All checks passed!
mypy                            -> clean (466 files; pre-existing PyYAML note)
pytest                          -> 6670 passed, 3 skipped

feat(console): the refusal now says how to GET the address it asks you to paste (#810)

What the operator saw
keel is running, and did not admit this browser

...the token is new every run and is never written to disk -- so if keel has been
restarted since this browser last worked, the address printed THIS time is the one
that admits it. Paste that address into the address bar here...

[ Paste the address keel printed, or just its token ]  [Reconnect]

Cause named well. Remedy named nowhere.

Two failures, and the second is the bad one

1. keel open is never mentioned. It is named in server.py:1125, in the launchd plist (“This process prints its URL… where nobody is reading. Do not grep the log for it. Run keel’s open command”), and in runtime.py’s own module docstring — everywhere except the screen the locked-out operator is looking at.

2. “never written to disk” is no longer true, and it is the sentence that closes the door. Since #756, that holds only for a server attached to a TERMINAL. Detached — launchd, a pipe, a container — runtime.py records the address in a 0600 file precisely so keel open can hand it back. A reader who believes the sentence concludes the token is unrecoverable and restarts the server, which mints a NEW one. refusedView’s own docstring already warns about that move: “sends them to restart the thing that is working… and makes the situation worse.”

Evidence it fails in practice: on 2026-09-16 two people who know this codebase were locked out by a daemon restart. One went to keel open --help; the other asked a colleague.

The change

An instruction block above the paste field — the command produces the value the field consumes, so meeting the field first means being asked for something you do not have:

To get this run’s address, from your keel deployment directory:

keel open --port 8765

It opens the console, and prints the address so you can paste it below. Attached to a terminal keel prints the address and keeps nothing; run as a service it records the address for that command and deletes it on shutdown.

The field’s label moves from “Paste the address keel printed” — past tense about something a daemon wrote to a log the plist says not to grep — to “Paste the address from that command”.

Boxed, monospaced, user-select: all, because its whole purpose is to be copied. The detail paragraph beside it runs five sentences; a command appended as a sixth is a command nobody sees.

Two constraints worth noting

The port comes from window.location, the same source and stated reason as timelineExportUrl and the deployment card: a fact about where this page is, not a claim from a payload that just refused us. Nothing else about the deployment is named — the path in particular. The port is the one fact the reader demonstrably already has (they reached this page on it), so naming it discloses nothing to a request that was not admitted. That begins to matter once #648 lets this console answer beyond loopback.

openCommandNode returns a node and joins nothing. render.js may use neither template literals nor + — test_render_contains_no_arithmetic bans the operator outright rather than try to tell string concatenation from money, and my first attempt tripped exactly that gate. append takes several strings, so the port is placed beside the command instead of joined to it. A default-port URL gives location.port as the empty string and takes the bare command, which keel open defaults correctly.

Verification

Six tests, each naming the mutation it rejects, asserted on code with comments stripped so the prose explaining the change cannot stand in for the change. Mutation-verified:

mutantresult
drop the command3 failed
hardcode the port1 failed
revert the label1 failed
tests/web                       -> 972 passed
ruff check keel tests packages  -> All checks passed!
mypy                            -> clean (467 files; pre-existing PyYAML note)
pytest                          -> 6673 passed, 3 skipped

Not changed: the server-side 403 detail text still carries the stale “never written to disk” sentence for non-browser clients. Worth a follow-up; this PR is the HTML view only.

feat(web): in-app profile switcher dropdown for multi-daemon navigation (#814) (#815)

Summary

Closes #814.

Implements an in-app profile switcher dropdown in Keel’s Honest Cockpit (keel serve / keel open), enabling operators to switch directly between running console daemons (live, paper, hourly, equities) via the browser header without invoking terminal commands.

Changes
  • Runtime Peer Discovery (keel/web/runtime.py): Added profile and mode to serving runtime record; added live_peers(current_port) to discover running daemon files in ~/.keel/run/, verify PID liveness and port connectivity, and clean up stale files.
  • Server Integration (keel/web/server.py): Supplies active profile name and auto-trade mode when recording serving status.
  • Config Payload (keel/web/payload.py, keel/web/api.py): Serializes verified peer metadata with wire-safe types.
  • Frontend UI & Event Wiring (keel/web/static/js/render.js, main.js, keel.css):
    • sessionChip: Renders <select class="profile-select"> when multiple active daemons exist, or static text when single.
    • deploymentCard: Lists all active profile peers.
    • main.js: Delegated change event navigates browser to chosen daemon URL.
    • keel.css: Light/dark theme styling for profile selector dropdown.
  • Tests (tests/web/test_profile_switcher.py): 5 unit tests covering record writing, peer discovery, stale cleanup, wire types, and DOM structure.
Verification
  • uv run pytest tests/web/: 977 passed in 153.91s
  • uv run ruff check keel/web/ tests/web/: All checks passed!
  • uv run mypy keel/web/: Success: no issues found in 9 source files

chore(release): 0.17.0 — rail vetoes on the cycle line, and a console switcher with no tokens (#818)

Version bump for 0.17.0. It moves the eight workspace versions and the inter-package == pins together, plus uv.lock. Same shape as 0.16.0 (#806): 9 files, 25 lines in and 25 out. uv lock --check passes.

Why a minor bump: #810 and #814/#816 add console features. There is no schema change since v0.16.0 (keel/data/ is untouched), so upgrading needs no new migration.

What’s in it since v0.16.0
  • #807: a quote-increment cache record from before quote_increment existed now counts as a cache MISS, not “unknown”.
  • #810: the “refused” page now explains how to get the address it asks you to paste. #817 fixes a duplicate const note in that change, which had blanked the whole console on main.
  • #813 (#812): the per-cycle log line now ends with vetoed=N (rail, …), so an entry the rails refused no longer reads as a silent drop.
  • #815 + #816 (#814): a console switcher next to the session chip. /api/config carries no session tokens; /switch/<port> sends the browser into the chosen console’s own sign-in, on local deployments only.
After merge
  1. Run Actions → Release with version 0.17.0.
  2. Install into ~/keel using the runbook’s “Deploying a new version” steps: back up each database, install the five wheels, run keel migrate, then keel versions and keel status.
  3. Restart the four long-running keel serve consoles, since they keep the build they started with.

v0.16.0

Voir cette version sur GitHub ↗

Built from c86de7738d05. Version binds to this hash: keel --version reports keel 0.16.0+c86de7738d05 [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.16.0-py3-none-any.whl
keel versions

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

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

Configure

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

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

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

Other changes

fix(execution): quantize bracket prices to the venue tick, in opposite directions (#802) (#804)

Closes #802.

The bug

Sizes were quantized (#513/#516). Prices were not.

2026-09-16 03:44Z, keel-live.db order id 5
executor.order_rejected  PAXG-USD SELL  error='Too many decimals in order price'

_bracket_spec floored base_size and passed stop/target straight through, so 4521.76390215979454 went on the wire. sizing.quote_increment_for looks like it covers this and does not — its own docstring says “the venue’s finest acceptable quote_size”, it is keyed on the CURRENCY not the product, and it is applied in one place: a BUY’s notional.

Every protective bracket was rejected. All three positions on the live deployment carry bracket_order_id = NULL, and the stop-loss/take-profit machinery has never fired there. Nothing distinguished that from DCA’s legitimate bracket-lessness, in any surface.

Direction is the substance, not a detail

quantize_down is documented as the safe direction for sizes — rounding a size up spends more than guards.check authorised. That reasoning does not transfer to a protective stop: rounding a long’s stop DOWN moves it further from price and widens the loss the position was sized against, silently, on every bracket.

So:

pricedirectionwhy
stop_trigger_priceuptoward safety — never widen the risk the position was sized on
take_profit_pricedowntoward reachability

quantize_up is a separate function, not a flag on quantize_down, because the two are justified by different arguments and a flag invites picking the wrong one by habit.

Note Decimal.__floordiv__ truncates toward zero rather than flooring, so the usual -((-v) // i * i) ceiling idiom silently returns the floor. It steps up explicitly instead, with a comment saying why.

A collapsed pair is refused, not sent

The two prices round toward each other, so a coarse tick can invert a pair that was valid before it. That raises BracketPricesUnplaceable rather than reaching the venue.

And the #799 shape is closed while we’re here

_bracket_spec was an inline argument to _run_order, so anything it raised left place_bracket past the if not result.placed recovery — exactly how a filled PAXG entry was stranded for three weeks. It is now built inside a try, and a bracket that cannot be BUILT is the same event as one the venue REFUSES: levels recorded to unbracketed:, a WARNING, a retry next cycle. There is a test asserting the retry record holds the RULE’s unrounded levels.

Where the tick comes from

Instrument.quote_increment, the field its own docstring anticipated (“would sit here naturally, but nothing reads them yet, and a field no caller reads is a field no test meaningfully checks”). It rides the existing per-product fetch and the existing cache record, so no extra venue round-trip enters the order-placement path. A pre-#802 cache record simply reads as unknown until it expires, which is correct — nothing knew the tick when it was written.

Unknown stays unknown: prices go unrounded, never refused. Refusing would leave a filled position with no stop at all, which is the #799 outcome.

Verification

Mutation: reusing quantize_down for the stop — the fix that still puts prices on the tick and would pass a naive “is it rounded?” check — is killed by three tests.

ruff check keel tests packages   -> All checks passed!
ruff format --check .            -> 503 files already formatted
mypy                             -> clean (465 files; pre-existing PyYAML stub note only)
pytest                           -> 6651 passed, 3 skipped
Deployment note

The live PAXG position is currently retrying its bracket every cycle and escalating reconcile.position_unprotected at CRITICAL. This is what lets that retry succeed.

fix(ledger): carry the owning rules.id on a position so exits are attributed (#803) (#805)

Closes #803.

The bug
SELL   PAXG-USD   rejected   unattributed   0.01320427

OrderIntent carries two different facts in two fields (guards.py:194-200): rule_kind, a KIND ("dca", "turtle_breakout"), and rule_id, the rules.id row. Entries thread the id from signal.rule_id. Both exit paths build their intent from a POSITION, not a signal — and positions stored only the kind, so there was no id to thread and every protective SELL was written with orders.rule_id NULL.

Not cosmetic: per-rule accounting reading orders.rule_id sees entries attributed and exits anonymous, which for a trend rule drops precisely the leg the outcome lands on.

Not caused by the demotion, which is the natural first guess — rule 3 went live -> paper shortly before this order, which looks exactly like a lookup that stopped resolving. The id was never on the intent; the row would read unattributed with the rule live. The migration docstring records that, so the next reader doesn’t re-derive it.

The change
  • v21: positions.rule_id INTEGER, nullable.
  • _open_tranche inherits it from the ENTRY order rather than taking a new argument. That order was already written with signal.rule_id, so every caller gets the attribution without one more parameter to forget.
  • place_bracket and scale_out take an optional rule_id and put it on the intent; the four call sites pass position["rule_id"] or signal.rule_id.
No backfill

The call v12 made for initial_stop. A tranche records its bracket order, never its entry order, so there is no join back to the id. Matching on (product_id, rule_name) would re-attribute by guess and be wrong wherever a rule row was replaced. NULL means “nobody recorded it”, and there is a test asserting a pre-v21 row reads NULL rather than acquiring an owner.

rule_id is optional everywhere, deliberately

There is a test for that on its own: a missing id must never cost a position its stop. A pre-v21 tranche has none, and refusing to protect it because the bookkeeping is incomplete would trade a reporting gap for an unprotected position.

Verification

Mutation: dropping rule_id=rule_id from place_bracket’s intent — which puts live order id 5 back in exactly the state that prompted this — fails the end-to-end test and nothing else.

Nine hardcoded SCHEMA_VERSION == 20 pins across three test files moved to 21, and three new migration tests cover the fresh schema, the no-backfill path, and idempotency.

ruff check keel tests packages   -> All checks passed!
mypy                             -> clean (465 files; pre-existing PyYAML stub note only)
pytest                           -> 6643 passed, 3 skipped
Note for the deployment

This is a schema change: keel migrate must run on all four databases before the new build trades. The ALTER is guarded by PRAGMA table_info and adds a nullable column, so it is non-destructive and idempotent.

Stacked behind #804 (#802) in spirit but independent in code — they touch different files and can land in either order.

chore(release): 0.16.0 — brackets reach the venue, and exits name their rule (#806)

Version bump only. No behaviour change in this PR.

Why MINOR and not a patch

#805 ships schema v21 (positions.rule_id). A deployment must run keel migrate on every database before the new build trades, and the version number is the only signal an operator gets before they read the notes. 0.15.1 was a pure bug fix and was numbered accordingly; this is not.

What lands
#804 (#802)Bracket prices quantized to the venue tick — stop up, target down. Until now every protective bracket was rejected with Too many decimals in order price, so the stop-loss and take-profit machinery had never fired on the live deployment. Nothing distinguished that from DCA’s legitimate bracket-lessness.
#805 (#803)positions.rule_id, so the protective SELL exiting a tranche names its owning rule. Both exit paths build their intent from a position, so every bracket, roll and scale-out was written unattributed while its entry carried the name.

Both PRs were reviewed and both reviews found a real defect the original missed — an unpatched second _bracket_spec call site in one, an untested inheritance line in the other. Those fixes are included.

What moved
  • eight workspace pyproject.toml version fields, 0.15.1 → 0.16.0
  • fourteen inter-package ==0.15.1 pins → ==0.16.0 (these are what a keel versions PARTIAL INSTALL comes from when left behind — this deployment has hit that before)
  • uv.lock, refreshed via uv lock
Verified locally
grep -rn '0\.15\.1' pyproject.toml packages/*/pyproject.toml  -> (none)
ruff check keel tests packages                                 -> All checks passed!
ruff format --check .                                          -> 504 files already formatted
keel/templates/config.live.yaml                                -> mode: confirm
keel/data/db.py                                                -> SCHEMA_VERSION = 21
pytest tests/execution tests/data tests/test_agent.py          -> 1134 passed
After merge — the order matters
  1. Actions → Release → Run workflow, entering 0.16.0
  2. Install the five production wheels by path into ~/keel
  3. keel migrate on all four databases — new column; the ALTER is guarded and nullable, so it is non-destructive and idempotent, but the build should not trade before it runs
  4. Restart the four console daemons

The live deployment has a PAXG position retrying its bracket every cycle and escalating reconcile.position_unprotected at CRITICAL. #804 is what lets that retry finally succeed.

v0.15.1

Voir cette version sur GitHub ↗

Built from 68656b507b30. Version binds to this hash: keel --version reports keel 0.15.1+68656b507b30 [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.15.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

ci(snyk): déclarer le dépôt public au CLI, sinon le scan est facturé comme privé (#796)

Le piège

Les plafonds du plan gratuit Snyk — 200 tests Open Source/mois, par organisation, partagés avec les autres dépôts de CodeGateSoftware — ne s’appliquent qu’aux dépôts privés. keel est public : ses scans devraient être gratuits et illimités.

Sauf que le CLI ne peut pas le deviner. Un checkout actions/checkout n’expose pas de remote exploitable, si bien que Snyk classe par défaut tout scan lancé en CI comme un test privé — y compris sur un dépôt public. --remote-repo-url est le mécanisme documenté pour le lui dire.

Sans lui, ce job consommerait le quota privé de l’organisation à chaque push sur main : ~196 par mois sur la période mesurée, soit à lui seul le plafond entier partagé par tous les dépôts.

Pourquoi maintenant, alors que rien ne casse

Le job est aujourd’hui skipped faute de SNYK_TOKEN/SNYK_ORG — vérifié sur huit exécutions consécutives. C’est justement le bon moment : le drapeau est posé pour que l’activation du scan ne vide pas le quota sans prévenir.

Posé sur snyk test et sur snyk monitor : les deux créent une cible, donc les deux doivent la déclarer publique.

fix(coinbase): guard the adapter’s preview Decimal conversions against empty fields (#799) (#800)

Closes the code half of #799.

The bug

_field(response, key, "0") supplies a default for a missing key. It does nothing for a key that is present and empty, which is what Coinbase sent. Decimal("") raises InvalidOperation.

-  est_base_size=Decimal(_field(response, "base_size", "0")),
+  est_base_size=Decimal(_field(response, "base_size", "0") or "0"),

…and the same for quote_size and commission_total.

Why it mattered

2026-08-25, live, cycle e917fb3d21544dc2:

13:20:20  executor.order_placed    PAXG-USD BUY  order_id=4  status=filled
13:20:21  executor.preview_failed  PAXG-USD SELL
          decimal.InvalidOperation: [<class 'decimal.ConversionSyntax'>]

The entry filled. The protective SELL’s preview then threw, and the exception propagated out of executor._run_order before agent.py’s repo.open_position(...) ran. Three weeks later that holding is still stranded: no positions row, so no channel exit and no 2xATR stop can ever fire; its computed stop of 4521.76 exists only in a log line (PAXG is now ~4312, so the stop was breached and nothing acted); and $61.71 still counts against the exposure caps, because _open_exposure_by_asset reads orders, not positions.

agent.tranche_not_recorded — the warning that exists for “entry filled but not recorded” — did not fire. It only covers a missing actual_fill/qty. This path throws earlier and logs a preview failure, which reads like a harmless pre-trade hiccup.

Why it was still live

The retired keel/data/cb_client.py already carries or "0". The adapter that replaced it does not — #524 flipped _build_broker to the registry-resolved adapter, so the fix landed on the path being retired and not on the path that replaced it. The August failure would recur today.

Test

Parametrized over the three fields. Each case asserts the emptied field reads 0 and that the other two still parse from the fixture, so a blanket try/except zeroing the whole Preview does not pass it. Verified by mutation: restoring the bare conversion for one field fails that field’s case alone, leaving the other two green.

ruff format --check and ruff check clean; mypy clean apart from a pre-existing PyYAML stub note in scripts/release_notes.py; tests/broker_coinbase tests/broker_api tests/execution → 602 passed.

Not in this PR

#799’s other three items are deliberately left open — they are design changes, not a one-line guard:

  • a filled entry must survive a later failure (record the position, or downgrade to an explicit UNBRACKETED state the codebase already models)
  • preview_failed on a protective leg after order_placed needs its own event and a doctor check for filled BUYs with no position row
  • reconciling live order id=4 is an operational decision, not a code one

chore(release): 0.15.1 — the adapter preview guard reaches the live path (#801)

Version bump only. No behaviour change in this PR — it exists so the fix already merged as #800 can actually ship.

Why now, specifically

~/keel reports keel 0.15.0+cd6f0d193bfe [release], and 0.15.0’s wheels predate #800. The deployed adapter still reads:

est_base_size=Decimal(_field(response, "base_size", "0")),   # unguarded

That is the line behind #799: on 2026-08-25 a live PAXG entry filled, the protective SELL’s preview raised decimal.InvalidOperation, and the exception propagated out of executor._run_order (deployed executor.py:979 previews inside it) before the position row was written — stranding funded inventory with no exit for three weeks.

The recovery for that position has now been armed on the live deployment (a positions row plus an unbracketed:PAXG-USD intent, so reconcile_unbracketed_positions re-brackets it). That recovery runs through the same preview path, on the same unguarded code, until this ships.

What moved
  • eight workspace pyproject.toml version fields, 0.15.0 → 0.15.1
  • fourteen inter-package ==0.15.0 pins → ==0.15.1. These are separate from the version field and easy to miss; left behind they are exactly what makes keel versions report a PARTIAL INSTALL, which this deployment has hit before.
  • uv.lock, refreshed via uv lock (keel-trader, keel-core, keel-broker-* → 0.15.1). The release workflow checks the lockfile before anything else and fails if it was left stale.
Verified locally
grep -rn '0\.15\.0' pyproject.toml packages/*/pyproject.toml   -> (none)
ruff check keel tests packages                                  -> All checks passed!
ruff format --check .                                           -> 502 files already formatted
pytest tests/broker_coinbase tests/execution                    -> 559 passed
keel/templates/config.live.yaml                                 -> mode: confirm

The last line is the release asset the workflow refuses to publish in any other mode.

There is one pre-existing E501 in docs/experiments/2026-09-02-equities-cost-fidelity.py. The workflow scopes ruff check to keel tests packages, so it does not gate this release; noted rather than silently fixed in a release PR.

After merge

Actions → Release → Run workflow, entering 0.15.1. Then the five production wheels install into ~/keel by path, and keel versions should report 0.15.1 across all five.

v0.15.0

Voir cette version sur GitHub ↗

Built from cd6f0d193bfe. Version binds to this hash: keel --version reports keel 0.15.0+cd6f0d193bfe [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.15.0-py3-none-any.whl
keel versions

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

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

Configure

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

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

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

Other changes

feat(web): the Status and Setup pages, one section at a time (#773) (#774)

Closes #773.

Both pages had grown past the point where an operator could see anything at a glance. Status scrolled through four state cards and four tables; Setup through the deployment card and then every step of both runbook stages, each a full card with its form — about 6,000px, with the go-live steps below the fold of the fold.

Each now shows one section, chosen from a tab bar:

pagesections
StatusStatus · Open positions · Rules · Data freshness · Subscriptions
SetupSetup · To run in paper · To go live

The control is the one scopeSwitch and statusSwitch already establish — nav.scopes / button.scopekey, aria-current carrying both the underline and the announcement, data-focus putting focus back on the pressed tab’s replacement. No CSS was added.

This is not a filter, and that is why it stays in the client

statusSwitch and scopeSwitch both re-ask the server, because both narrow a collection the server capped on the way out — filtering a received page in the browser would present “the rows that happened to arrive” as the whole answer.

This control narrows nothing. /api/status sends every section of the status page in one document and always did; choosing which of them to draw is presentation, and no section is ever unreachable — every one of them has a tab, always. So there is no ?section=, and no “all” tab: the way back to any section is that section’s own tab, and an “all” tab would re-create the page these two views were cut up to stop being.

The sections are a table, not five branches

STATUS_SECTIONS and SETUP_SECTIONS pair a key, a label and a builder, so the bar cannot offer a section the page has no builder for. The label is written once and reaches both the tab and the section’s own heading — each builder is handed the entry it was reached through — which forecloses a tab and a heading disagreeing about which section a reader is looking at.

STAGES is folded into SETUP_SECTIONS for exactly that reason: it carried a second copy of “To run in paper”.

An unwired onSection renders every section with no bar — the page exactly as it read before — rather than one section with no way to reach the others. Same guard as ordersView’s onStatus, and the same reason: a control nobody wired must not cost a reader anything.

The section is kept per route.name in main.js and survives navigation, so the two pages remember their sections independently. The press forces the repaint: unforced, the rebuild is deferred while focus sits on the button that was just clicked, and a control that does nothing is indistinguishable from a broken one.

Tests

tests/web/test_view_sections.py (18 tests) pins the tables as parsed out of the JavaScript rather than restated:

  • both tables declare the sections in the page’s own former reading order;
  • every section has a builder of its own — a table pointing five entries at one function would satisfy every label assertion and render one section under five tabs;
  • every key is distinct, and each of the four status headings is written by the builder its entry names;
  • no builder re-spells its label; if it writes a heading, it writes entry.label;
  • neither view dropped a payload key in the split (_view_keys against /api/status and /api/setup, pinned);
  • one sectionSwitch, one sectioned, and the two bars announce themselves differently;
  • the shell keys sections by route.name and forces the repaint.

tests/web is green: 835 passed.

Verified by hand, since the suite cannot

There is no JavaScript runtime in this repository, so what the source-text suite cannot establish was checked against a running keel serve:

  • all five status tabs and all three setup tabs render their section and only their section (paper 7 step cards, live 5);
  • focus lands back on the pressed tab after the rebuild;
  • each table keeps its heading as its aria-labelledby;
  • Setup’s “To go live” and Status’s “Rules” are remembered independently across navigating away and back;
  • console clean, no errors or warnings.

Page height: Status 1,900px → 619–1,074px. Setup ~6,000px → 930px on its first section.

fix(web): the section tabs had tests that passed on broken code (#775) (#776)

Closes #775. Follow-up to #774.

A post-merge review ran thirteen defects through the client. Twelve passed the suite. The one that failed did so by accident — caught by a payload-key pin, not by any test aimed at it.

The cause was uniform: the assertions were "onSection" in body and "data-focus" in body. A source-text scan passes on a declaration alone; onSection is in the signature. Naming an identifier proves the author typed it, never that it is wired to anything.

Mutation score
beforeafter
defects rejected1 / 1313 / 13

Each mutation is applied to the client, the mutated bytes are read back off disk (a no-op mutation looks exactly like a killed one), the suite runs, and the file is restored in a finally. Every test below names the mutation it exists to reject and was proved red against it.

targetmutationwhat shipped
sectioneddelete the if (!onSection) blockthe documented “unwired renders everything” degradation, gone
sectionedconst here = sections[0];bar renders, presses recorded, the page never changes section
sectioneddrop the sectionSwitch appendsection one is the only reachable section
sectionSwitchone shared data-focusevery keyboard press dumps focus onto tab one
sectionSwitchunconditional aria-currenta screen reader is told all five sections are current
stageStepsfilter hardcoded to "paper"the To go live tab renders the paper checklist
stageStepsdelete the blurb / the empty-stage line / hardcode the headingthe paper-is-safe argument disappears
setupLiveforward SETUP_SECTIONS[1]its neighbour’s stage under its own tab — the mutation its docstring called unspellable
statusFreshnessa second heading("h-positions", …)two elements share one id; a table announced under the wrong heading
onSectionForsections.set("status", key)Setup’s presses land in Status’s bucket; Setup can never leave section one

stageSteps — the function that draws every Setup step — had no test of any kind.

The regression that shipped

market_data is a Stage.PAPER step, so Fetch market data is pressed from the To run in paper tab — and the panel reporting it was inside the Setup section. The job’s state line, elapsed time, streamed transcript and job.error were all invisible to the person who started it.

jobPanel’s docstring keeps a failure on screen because “the whole point of running something in the background is that nobody was watching when it broke”. The split defeated that structurally. setupSummary’s docstring justified the placement with reasoning that was exactly backwards; the correction is written where the claim was.

The panel is page-level state, like the <h1>. It renders above the switch now.

Two structural traps, named so they are not re-dug

_decl bounds at the next top-level declaration, not at the next \nfunction . The old bound sailed past export function and const — measured, what it called statusSubscriptions’ body swallowed all of setupView and the whole SETUP_SECTIONS table. Every count taken over it was counting the wrong thing.

A test guarded by if "heading(" in body is a test that can skip itself. That guard made the label-to-heading check assert nothing for all three Setup sections, because Setup’s heading is written one hop down in stageSteps — so the page whose duplicate wording motivated the whole table was the page nothing was checking. _delegate follows the hop.

Smaller
  • sectioned/sectionSwitch named their Section[] parameter table, shadowing the module’s row renderer. test_every_call_resolves_to_something_the_module_has scopes parameters per function, so it would keep passing while a table(...) added inside either resolved to an array and shipped a runtime TypeError — the miss that test’s own docstring records from #701.
  • Their payload parameter is payload, not data: both sit inside activityView’s _view_keys region, where a data.<key> would be checked against /api/activity. The rename makes the mistake unspellable rather than merely unmade.
  • onSectionFor now names what the round trip costs — a read that fails replaces the section with stoppedView, for a document the client was holding in full, with no aria-busy while it waits.
Verified

tests/web: 843 passed. In a browser against a running keel serve: the job panel renders above the tab bar on all three Setup sections with its transcript intact, and the sections still show 7 paper steps and 5 live steps.

fix(test): the orders book fixture failed for sixty seconds of every UTC day (#777) (#778)

Closes #777. Fixes the red on main from run 34419113683.

FAILED tests/web/test_orders_view.py::test_the_scope_is_normalised_and_echoed_rather_than_refused
  assert '0' == '1'
Why it failed

?scope=today is a UTC calendar day, not a rolling window: scope_start_ts returns 00:00 UTC of the current day and gather_orders skips a row when created < start_ts. The book fixture placed its live order at now - 60.

That run’s started_at was 1788998401, and 1788998400 is exactly 20706 × 86400 — one second past UTC midnight. The row landed at 23:59:01 on the previous UTC day and fell outside the scope it exists to be inside.

windowfrequency
beforefirst 60s of every UTC day~1 run in 1,440
afterfixture/server clock reads straddling midnight~1 run in 10,000,000

Green on every rerun, which is what makes it the kind of failure that gets rerun rather than read.

Not from #773/#774 — that PR touched render.js, main.js and a new test module. This fixture dates from #659.

The fix

max(now - 60, scope_start_ts("today", now)), extracted into _recent_ts so it can be tested without waiting for midnight.

Still anchored on the real clock, for the reason the fixture already gave — a fixed future instant would put every row inside every scope and make the scope assertion pass without the scope doing anything. Clamped, not replaced: the row stays a minute old on all but one run in 1440. The bound is inclusive (created < start_ts is what skips), so landing exactly on it is inside.

The pin is a real one

Parametrised at five points around the boundary. Reverted to now - 60 it goes red at 00:00:01 and 00:00:59 and stays green at 00:01:00, midday and 23:59:59 — it discriminates the flake window from the rest of the day rather than merely asserting the helper exists.

A second test holds the other half of the pairing: the 90-day-old row must stay outside today. A clamp that dragged both rows into today would make the scope assertion pass while proving the scope does nothing.

What is deliberately left open

The fixture reads the clock and the server reads it again a moment later. Reads that straddle midnight place the row in the old UTC day and judge it against the new one — the setup-to-request gap, order of 100 ms.

Closing it needs a sleep past the boundary in a fixture, or a skip guarded on the echoed scope_start_at. The second is a test that can decide not to run — the failure #775 is about — because were scope_start_at ever wrong, the guard would silently skip forever instead of going red. The residual is written into _recent_ts’s docstring rather than papered over.

Verified

tests/web + tests/commands/test_orders.py: 926 passed.

fix(web): every reader answers on the clock it was handed (#779) (#780)

Closes #779. Step 1 of the two agreed items.

respond reads time.time() once per request and threads it to route.read(cfg, query, state, now_ts). Sixteen readers use it. Two took it as _now_ts and read the clock again for themselves:

read_activity   apply_scope(feed, scope, now_ts=time.time())
read_orders     gather_orders(repo, now_ts=int(time.time()), ...)
What that cost

The document reported two instants. The envelope’s generated_at came from the dispatcher’s reading, the scope boundary from the reader’s a moment later. Across a UTC midnight those are different days, and ?scope=today answers about a day the page does not claim to be showing.

Not theoretical — #777 is the day it cost a red CI run.

And it made those two the only readers a test could not choose a clock for. #778 had to monkeypatch.setattr(web_api.time, "time", …), patching the time module process-wide, to get a deterministic reproduction. That is no longer necessary anywhere on the read surface.

No behaviour change

now_ts is computed per request at the top of respond (api.py:1205) — verified before touching it, because had it been computed at server start, wiring it up would have frozen the clock in production. This is the same instant, read once instead of twice.

read_setup, read_plans, read_rules, read_venues and read_gates keep _now_ts: they are genuinely time-free.

Tests

Each reader is called directly with a now_ts a decade from any wall clock, so a reader that ignores its argument cannot agree by coincidence — the same test at an instant inside today passes either way, which is why both are parametrised. The activity boundary is compared against scope_start_ts’s own answer, not a restated literal.

test_no_reader_reads_the_clock_a_second_time keeps the rule after these two instances are gone.

A bound that lied, caught in passing. My first version of that scan bounded each function at the next def read_, which swept helper functions into the preceding reader and reported five clock reads inside read_gates — which has none. Same region-bounding error _decl carried in #775. The scan bounds at the next top-level statement, and the docstring says why.

mutationresult
read_orders back to int(time.time())KILLED
read_activity back to time.time()KILLED
dispatcher’s own now_ts → a constantKILLED

The third matters: it proves the injection is real end to end rather than shadowed by a reader that would have read the right value anyway.

Verified

tests/web + tests/commands/test_activity.py + tests/commands/test_orders.py: 1112 passed. ruff, format and mypy clean.

fix(test): the clock-injection tests never exercised the seam they were about (#782) (#784)

Closes #782. Follow-up to #780.

#780’s product change was correct — traced and confirmed: float→int loses nothing, now_ts is per-request on every path including SSE, the rename is complete. This PR touches no product code. Its tests were the problem.

mutations killed
before2 / 11
after11 / 11
The seam with no test

respond reads the clock once and threads it to route.read(...). That sentence is the whole of #780 — and all three of its tests called the readers directly. Nothing in the module called respond; generated_at appeared only in docstrings, never in an assertion.

mutationbefore
a day-old clock threaded to every reader204 tests pass
now_ts hoisted to a module constant, read once at import204 tests pass
the value floored to UTC midnight204 tests pass

The second would have a long-running keel serve answering every request on its boot instant. test_respond_hands_the_reader_the_same_instant_it_stamped goes through the dispatcher and asserts both halves, because they fail to different mutations: as_of catches a value altered before stamping, scoped_count one altered before being handed down.

A test that passed having scanned nothing

test_no_reader_reads_the_clock_a_second_time built offenders from a loop and asserted it empty — with nothing pinning that the scan found anything. It walks 43 top-level definitions and classifies 18 readers, and passed identically when narrowed to classify zero. The population is now cross-checked against API_ROUTES, the list the server actually dispatches to, so it cannot drift into a number nobody maintains.

The scan is an ast walk now, which fixes four things at once:

  • a third offender written int(datetime.now(tz=UTC).timestamp()) escaped — both names are already imported in api.py, so it is a one-line in-idiom defect;
  • so did one hidden a call deep in a module helper — the shape the two original offenders take after any extract-method;
  • it false-positived on a module constant like _SERVER_STARTED_AT = int(time.time()), blaming whichever reader preceded it;
  • and its docstring said “bounded at the next top-level statement” while the code bounded at the next def/class.
A fixture that never reached the reader

The activity test pointed tmp_path at a config and asserted scope_start_at. But resolve_log_path’s default is relative and resolves against the process CWD — verified, it resolves to <checkout>/logs/keel.log. So the config was decoration, the feed was empty, and the only assertion left was on a pure function of now_ts that no feed can influence. Making apply_scope filter on the wall clock while still echoing the injected boundary passed everything.

It owns its log now — a cycle either side of the boundary, an absolute logging.file — and asserts the cycle that survived, not the label.

Two smaller

The orders fixture gains a third row, one second before the boundary. The scope is a lower bound with no upper bound, so a clock shifted backwards only widens the window; with rows only at the boundary and two days out, now_ts - 86_400 changed nothing and escaped.

orders.scope_start_ts is pinned against a literal midnight. + 1 on its return passed the whole suite because _book_at places its boundary row by calling the same function — row and boundary moved together and the mutation cancelled itself out. A test that computes its expectation from the code under test cannot see that code move.

Verified

tests/web + tests/commands/test_activity.py + tests/commands/test_orders.py: 1115 passed. ruff check and mypy (458 files) clean.

No ruff format run this time — see #783, which is about the repo being 40% unformatted with no CI gate. The file happens to be format-clean anyway.

chore: one-shot ruff format across the tree, and gate it (#783) (#785)

Closes #783. Mechanical. No behaviour change, with one syntax change called out below.

ruff format was configured and clearly intended — ruff.toml sets its line-length and target-version and carries a careful note about a hazard someone reasoned through — but nothing ran it, and 197 of 496 files had drifted.

The cost wasn’t aesthetic: a contributor who ran the formatter on a file they touched reformatted regions they hadn’t, and those hunks landed in PRs whose bodies said “no behaviour change”. #780 shipped six of them and a reviewer had to read each to establish it meant nothing.

197 files reformatted, 299 left unchanged
+3427 / -1960
directoryfiles
tests/112
keel/55
docs/17
packages/10
scripts/3
The gate

ruff format --check . in both ci.yml and release.yml. Both, because release.yml is dispatched against whatever main is at the time, which need not be the commit CI went green on — the same argument that file already makes for re-running mypy there.

Whole tree, where ruff check in the same workflows is scoped to keel tests packages. Lint has a debt in docs/ and scripts/; formatting no longer has one anywhere, and scoping the check would re-open the drift this pass closed. It’s its own step, so a formatting failure reads as one instead of being masked by a red lint on the same run.

⚠️ One change is not cosmetic

The pass rewrote 38 occurrences of except (OSError, OverflowError, ValueError): into PEP 758’s unparenthesised except OSError, OverflowError, ValueError:.

That is exactly what ruff.toml’s existing note predicted, and why that note is there: under the old >=3.11 floor the same rewrite emitted a SyntaxError on three interpreters the metadata invited. The floor is >=3.14 now — verified across all eight distributions, and pinned by tests/test_python_floor.py (test_every_distribution_declares_the_same_floor, test_ci_runs_the_suite_on_the_floor_so_it_stays_true). CI runs 3.14 only.

If you’d rather not carry that syntax, say so and I’ll add ruff format’s skip for it — but the floor makes it safe and the note in ruff.toml already anticipated this day.

Verified

The real risk in a mechanical reformat of this repo is its many source-scanning tests — inspect.getsource assertions, _code_only walks, exact-string pins. None broke:

  • full suite: 6477 passed, 3 skipped
  • ruff check keel tests packages clean
  • mypy clean (458 files)

And the gate is checked rather than assumed: both workflows still parse as YAML, both carry the Format step, and ruff format --check . against a single deliberately misformatted line exits 1.

Reviewing this

Not with git diff --ignore-all-space — that was in the first draft of this body, it was never run, and it is wrong twice: it leaves 199 files and 3308 insertions (no useful reduction), and it hides the one change below that is not whitespace.

Instead, the reformat is proved neutral rather than eyeballed. All 197 files were AST-compared against main:

  • 186 AST-identical, 0 unparseable
  • the 38 except rewrites parse to a byte-identical AST — except (A, B): and except A, B: are the same tree, so that half is neutral by construction
  • 11 files differ, all the same way: the formatter inserted a leading space into docstrings that begin with a quote (""""HALTED" → """ "HALTED"). That is a string value change, not whitespace.

Two of the 11 are in keel/ (_rail11_status, _render_tier_section) — both private helpers. No Click command’s help text starts with a quote (checked by AST across keel/), so no --help output moved.

Review follow-up

Five findings, all fixed in 0bcfd4f: the gate is now pinned by three tests (deleting it from either workflow, scoping it, or moving it after Stamp build info all go red), the count is corrected to 38, and CONTRIBUTING.md / the PR template / test_the_gates_a_pr_must_pass_are_stated all carry the new command.

feat(web): the Tier 1 gate’s security primitives, with no action wired (#781) (#786)

Stage 1 of #781. Builds the gate, wires nothing to a rail.

That ordering is deliberate: at no point should a half-built surface exist that can reach disengage_kill_switch. The invariant in keel/web/__init__.py — “not one of the nine capability-increasing actions is reachable from this package” — is still true after this PR, and its AST scan passes untouched.

⚠️ A correction to the brief, found before building

The specified guard was “refuse unless the request originated from 127.0.0.1”. That is necessary and not sufficient.

A Cloudflare Tunnel runs cloudflared on this machine and connects to keel over loopback. So a request that began on the public internet arrives with client_address of 127.0.0.1. A guard reading only the peer answers truthfully and uselessly — and would let whoever can reach the tunnel release a rail, which is the exact population #781 exists to keep out until #648 and #656 land.

gated_action_permitted therefore requires both:

conditionwhy
the peer is loopbackthe socket’s remote address is the one thing in a request an attacker cannot choose
external_hosts is emptythis is how the module already spells “remote posture”

The second isn’t invented for this PR: session_expired_at turns a never-expiring session into a 30-day one on exactly that field, reasoning that a remote origin “changes the population to anyone who can reach the tunnel”. Same signal, same reason, one decision further along.

What’s here

is_loopback_peer reads the peer address, never a header. Host: and X-Forwarded-For: are claims the client writes, and this check exists for the case where the claim is a lie. (HostPolicy.permits reads the header and defends a different thing — DNS rebinding.)

It accepts the whole 127.0.0.0/8 block rather than one address — systemd-resolved answers on 127.0.0.53, and == "127.0.0.1" would refuse a local operator with a false refusal that reads as a broken button — plus ::1 and the IPv4-mapped ::ffff:127.0.0.1 that a dual-stack bind presents for a v4 client. It fails closed on anything it cannot parse.

The parameter is object, not Any, so mypy enforces the narrowing rather than waving it through: what arrives is BaseHTTPRequestHandler.client_address, which typeshed declares a 2-tuple and which is a 4-tuple on IPv6.

gates_token derives the write token for a gated action exactly as csrf_token derives the setup one, under a different label. That difference is the property: the setup token is written into /api/setup’s document, so anything that can read that page holds it — and one value covering both surfaces would make “can create a deployment” and “can release a halt” the same permission. Same secret, two derivations, neither usable as the other, each revocable alone.

This is the “separate token” call from the plan, done the way this codebase already does domain separation rather than by adding a second secret to manage.

Also

tests/web/test_server.py said “eight capability-increasing actions”; there are nine. The three keel/web/ sources say nine correctly and are pinned by test_the_disjointness_comments_state_the_actual_capability_count — which scans keel/web/ only, so it could not see the test’s own prose. Fixed here, since this is the machinery being extended.

Verified

tests/web + tests/test_capabilities.py: 900 passed. ruff, ruff format --check and mypy (458 files) clean. 13 new tests, each parametrised over the cases that matter — the loopback block, the IPv6 forms, the malformed peers, and the tunnel.

Stage 2

Wires the three brake-releases (resume, resume-entries, reset-hwm) behind this gate plus the typed-phrase modal, and the ungated one-click Halt. That stage is where keel/capabilities.py gains its second Gate and where the __init__.py invariant gets rewritten — deliberately, in the PR that earns it.

feat(web): the browser gate and its closed action table, still wired to nothing (#781) (#787)

Stage 2a of #781, on top of the merged #786 primitives.

keel/capabilities.py has said since it was written that “#436’s browser gate would be a second Gate here and a second value in Capability.gate — which is the point of writing the vocabulary down before there are two.” This is that second kind of evidence, as its one implementation, symmetrical to _require_interactive_confirmation being the one implementation of the first.

Nothing routes to it yet. The invariant in keel/web/__init__.py — not one of the nine capability-increasing actions is reachable from this package — is still true, and its AST scan still passes: TIER1_ACTIONS names resume, resume_entries and reset_hwm as strings, never imports or calls them. The dispatch that will call them belongs in server.py, so this module stays a declaration.

The table is closed, and the closure is the property
keyreleasesphrase
resumethe kill-switch halt that stopped all tradingRESUME TRADING
resume-entriesrail 16’s consecutive-loss halt on new entriesCLEAR STREAK HALT
reset-hwmrail 11’s drawdown veto, by re-seeding the high-water markRESET HIGH WATER MARK

The other six are refused here as firmly as they are refused to cron — and the tests name autonomy-on, record-flow, withdrawals-attest and update individually, asserting each is absent. Admitting one means deleting a test that states why it was out, rather than adding a line nobody reviews.

Why these three is written where the table is. All three release a brake a rail applied — deliberately not described as risk-reducing, since they increase what keel can do. What makes them Tier 1 is that the capability is bounded and instantly reversible: the rail that fired can fire again, and keel kill undoes all three with no ceremony, because stopping must never be slower than starting. The other six grant standing capability or move a baseline — record-flow rebases the drawdown high-water mark, which capabilities.py calls “the one direction a circuit breaker must not fail in”.

The phrase

Exactly, meant literally: not case-folded, not stripped, not prefix-matched. The operator reproduces one specific sentence, and every loosening turns a deliberate act into a plausible typo. Pinned: resume trading, a trailing space, and a truncation all fail.

Never the action’s own key, and no two share one — a phrase derivable from the URL is evidence of nothing, and a shared phrase would prove a human but not intent. compare_digest because it costs one call and removes the timing question (belt-and-braces; the modal prints the phrase).

phrase_matches fails closed on the key as well: an action absent from the table has no phrase, and “no phrase” must never read as “no phrase required” — which is what a lookup with an empty default would quietly mean.

One of the repo’s own pins caught me

The module’s first title line read “Tier 1 capability-increasing actions”, which test_the_disjointness_comments_state_the_actual_capability_count correctly read as a disjointness claim naming N=1 against nine. It scans every keel/web/ source for that sentence shape because the number “has drifted twice already”. Reworded to “three of the nine capability-increasing actions” — which states the truth and satisfies the pin for the right reason.

Verified

tests/web + tests/test_capabilities.py: 925 passed. ruff, ruff format --check and mypy (460 files) clean. 12 new tests.

What’s next

Stage 2b routes to this — the POST /api/gates/* gauntlet, the three call sites, the second Gate in keel/capabilities.py with three surface="web" rows carrying mirrors to the CLI originals, and the rewrite of both the __init__.py invariant and test_the_only_gate_today_is_the_tty_one. That test’s own docstring says it “is where that change announces itself — so adding one cannot be a quiet edit”, which is why it gets its own PR rather than riding along here.

Stage 3 draws the modal and the ungated one-click Halt.

fix(web): the safety invariant scanned command names, not effects (#788) (#789)

Closes #788. Standalone, off main. No production code changes — this is the guard, not the thing guarded. Nothing in #781 depends on it landing a particular way, but stage 2b should not be built on top of it as it was.

The gap

keel/web/__init__.py states the guarantee the whole package rests on:

“Not one of the nine capability-increasing actions is reachable from this package… The server cannot arm, release or spend.”

It did not assert that. The scan forbade this package from naming any Capability.function — and those are the CLI commands, Click callbacks taking a ctx that no web module would ever call. What releases a rail is the operation underneath:

gated actionscanneddoes the work — not scanned
keel resumekeel.cli.resumetrading.disengage_kill_switch
keel resume-entrieskeel.cli.resume_entriestrading.clear_consecutive_loss_halt
keel reset-hwmkeel.cli.reset_hwmtrading.reset_high_water_mark

Planting an ungated disengage_kill_switch(repo) into a web module passed the entire safety suite. The guarantee held because nobody had written the line, not because the test would have caught it.

What is asserted now

The effects, derived. For each of the nine, the functions its gated command actually calls, read out of the source — never a hand-written map. keel/capabilities.py exists because “a hand-written list would go stale”, and a table saying “keel resume really means disengage_kill_switch” would be that same list one level down. The derivation asserts it finds ≥6 operations, so it cannot go quietly empty.

And no database write at all. Four of the nine work through a generic repo.set_state(...) that no derivation can tell from any other write. So the third scan states the posture directly: this package calls none of the repository’s 28 writers. Measured — keel/web/ calls five Repository methods and all five are reads. That is also the more honest sentence: keel serve reads, and everything it can change goes through keel.commands.setup.ACTIONS, outside this package.

The original name scan is kept. It’s cheap, it still catches the obvious spelling, and deleting a check because a better one arrived is how the next gap opens.

Measured against the old one

Five real effects planted in keel/web/staticfiles.py:

plantedold scannew
ungated disengage_kill_switch — the #788 proof❌ missed✅ caught
ungated clear_consecutive_loss_halt❌ missed✅ caught
ungated record_declared_flow✅ caught✅ caught
a raw repo.set_state(…) write❌ missed✅ caught
apply_scope_attest❌ missed✅ caught

test_the_effect_scan_can_fail proves the derivation and the scan can match — the same shape as the existing test_the_scan_for_capability_increasing_actions_can_fail, which proved the old scan could match a name, which is exactly the thing that turned out not to be enough.

keel/web/__init__.py now says what is checked rather than what was hoped.

Verified

tests/web + tests/test_capabilities.py: 916 passed. ruff, ruff format --check and mypy (458 files) clean.

After this

Stage 2b lands on a scanner that can see the three operations it routes to, so its assertion becomes the precise one: those three reachable only through the gated handler, the other six unreachable anywhere in keel/web/.

fix(web): drop reset-hwm to Tier 2, and bind every phrase to its own action (#790) (#791)

Closes #790. Eleven findings on the gate table merged in #787. Mutation score 11/20 → 17/17.

The Tier boundary argued against itself

reset_high_water_mark is four writes — it clears the mark, zeroes both drawdown scalars, and empties equity_history, the rolling window the weekly peak is computed from. record_declared_flow shifts the mark by a signed amount the operator states.

So Tier 1 admitted the action that erases the drawdown evidence while Tier 2 refused the one that adjusts it, on the stated grounds that the adjusting one “can, with the wrong sign, mask a real trading drawdown”.

Dropped to Tier 2. The alternative — keeping it and rewording the criterion — would have been fitting the rule to the answer. Tier 1 is resume and resume-entries.

Two related corrections went in with it:

  • resume doesn’t release a brake a rail applied. guards.check reads get_state("kill_switch", default=True) — unset means engaged — so on a never-resumed deployment it moves a fail-closed default to permissive. It stays, because keel kill re-engages in one gesture, and that is what bounds it. The entry now says so.
  • Stop must ship no later than release. The browser has no halt today. Stage 2b ships the ungated one-click halt in the same change as the first release route, not after it. Written into the module rather than left as a plan.
A phrase that released every action

Every test read the expected phrase back out of the table, so the suite was self-consistent under any content. A phrase_matches ignoring action_key and testing against every row passed all twelve — making any Tier 1 phrase a master key, which is the closure this module calls its safety property, defeated. Nothing pinned the literals either: RESUME TRADING → RESUME NOW shipped green, as did swapping two actions’ phrases.

Bound now, and asserted as a cross product: every phrase against every other action. Literals pinned. And no two phrases may share a first word — the near miss is an operator pattern-matching under stress, for whom a future RESUME ENTRIES beside RESUME TRADING would be genuinely confusable.

The same bug I had fixed one PR earlier

secrets.compare_digest raises TypeError on a non-ASCII str and on a non-str. I found and fixed exactly this in tokens_match during #786’s review, then wrote the same pattern here without the guard. The phrase comes off an operator’s keyboard through a JSON body, so a smart quote or a non-breaking space from a phone delivered the correct verdict — refuse — as a 500.

The rest

function named a click.Command. getattr(keel.cli, "resume")() would enter Click standalone mode, re-parse the server’s sys.argv, run the TTY gate with no terminal, and sys.exit inside a request thread. It’s operation now — the state service, module-qualified, pinned by literal and checked to resolve to something callable. Two actions may not reach one operation, which would release the kill-switch when the operator asked to clear a streak halt.

TIER1_ACTIONS is a MappingProxyType — a plain dict let any importer add autonomy-on in one line.

action/detail are imported from keel.commands.trading, not paraphrased. That module holds the wording so “neither re-words them”, and the drift was already visible: the CLI printed “Any real, unrecovered drawdown stops being visible to the rail” where this said “re-seeding the high-water mark”. A test asserts both strings are the module’s own.

All seven non-Tier-1 actions are named individually — scope-attest and posture-attest were missing, so either could have been added for free. And the module no longer describes wiring that doesn’t exist in the present tense.

Two tests that were theatre

The timing test grepped the source for compare_digest; a mutant replaced the call and left the word in a trailing comment. == and compare_digest return identical values for every input, so no black-box test can tell them apart — it reads the AST now and requires the returned value to be that call.

Writing it turned up the same class of bug in my own test: ast.walk is breadth-first, so returns[-1] is not the last return in the source. It passed by luck and failed the moment an import edit shifted the lines. Sorted by line number now, with every earlier return asserted to be a refusal constant.

Six of the twelve original tests were vacuous on an empty table. Every loop and count now asserts its own population first.

Verified

17/17 mutations killed. tests/web + tests/test_capabilities.py: 940 passed. ruff, ruff format --check and mypy (460 files) clean. Still nothing routes here — keel/web/__init__.py’s invariant is unchanged.

feat(web): the browser can release two halts, and stop from anywhere (#781) (#792)

Stage 2b of #781. POST /api/gates/* routes to the gate table, and the browser stops being read-only for the first time.

The gauntlet, and the order is the contract

A release must survive, in order:

_admittedHost policy, session cookie, expiry
_sec_fetch_site_ok
X-Keel-Clienta header a plain HTML form cannot set
gated_action_permittedloopback peer and loopback bind and no declared remote origin
gates_tokenin a header of its own, derived apart from the setup token
the typed phraseexact, checked last

Nothing opens a database until the phrase matches. The test that pins it points db_path at a path that does not exist and asserts a wrong phrase still returns rather than raising — the ordering asserted, not described. And no refusal echoes the phrase: a 403 quoting it would put a credential-shaped string into logs and into whatever an operator pastes while asking for help.

Stopping is never gated on locality

/api/gates/halt takes no phrase and no locality check. It needs the session and the gate token, because it is a write; nothing else, because an operator whose keel is behind a tunnel must be able to stop it from the tunnel. A stop refused for being remote is the one failure this surface must not have.

It ships in this change, not after it — a release route landing first would mean starting was available from a phone and stopping was not.

The second gate announces itself

keel/capabilities.py gains BROWSER beside TTY, and two web rows carrying mirrors to their CLI originals — the shape mirrors was kept for, “the moment a second front-end gates anything again”.

test_the_only_gate_today_is_the_tty_one said it “is where that change announces itself — so adding one cannot be a quiet edit”. It is test_the_gate_vocabulary_is_stated_and_bounded now: the assertion moved rather than being deleted, which is the point of having written it that way. Two new tests name which actions the second gate covers and require every browser row to mirror a TTY row.

Three invariant scans are scoped to TTY rows, because a web row’s function is the browser gate’s own implementation and necessarily lives in keel/web/ — forbidding it there would forbid the gate from existing. What the browser reaches is Tier1Action.operation, pinned from both sides by test_the_only_permitted_effects_are_the_ones_the_gate_table_names.

⚠️ One guard was invisible to its own tests

Deleting gated_action_permitted from the route passed the entire suite. Every routing test ran against a loopback server, so the check always passed and removing it changed nothing observable — the single most important guard in the feature, untested at the level it runs.

Fixed by testing the posture that makes it bite: a deployment with --external-host set refuses a release and says the action remains available at the terminal; the halt on that same deployment succeeds, pinning the asymmetry rather than leaving it to a docstring.

mutationbeforeafter
loopback check removed❌ escaped✅ killed
halt branch removed (halt 404s)❌ escaped✅ killed
gate token check removed✅✅
gate token swapped for the setup token✅✅
unknown key not refused✅✅
phrase not checked✅✅
database opened before the phrase✅✅
refusal echoes the phrase✅✅

8/8, from 5/8.

Verified

Full suite: 6557 passed, 3 skipped. ruff, ruff format --check and mypy (460 files) clean.

The MCP capabilities tool asserted a single gate; it now reports what the inventory declares, derived rather than spelled.

Reviewing this

This is the PR where a rail opens to a browser, so it deserves the same adversarial pass the four before it got. The places I would look hardest: whether _gated_action’s early returns can be reordered without a test noticing, whether the halt’s exemption from the locality check is exploitable on a tunnelled deployment beyond the DoS-in-the-safe-direction it is meant to be, and whether the TTY-scoping of the three scans widened anything I have not noticed.

Stage 3 draws the modal and the halt button.

feat(attestations): one model, three surfaces, and the four numbers that disagreed (#793) (#794)

Closes #793.

Live stopped placing orders on 2026-08-25 and nobody noticed for 17 days. The exposure caps were the primary cause, but rail 17 expired on 09-08 and stacked on top — and nothing anywhere said so until someone went looking in the veto log.

The model

keel/attestations.py answers, for every attestation keel has: what is it, when was it made, when does it expire, how long is left. It reads each rail’s own constant and restates none of them — rail 17 derives its expiry from executor.WITHDRAWAL_ATTESTATION_TTL_SEC at call time; rail 22 uses the due date stored at attest time, which has no default precisely so a reader cannot cover for a writer that forgot one.

doctor.TTL_SEC = 7 * 86_400 is deleted. Its comment read “Rail 17’s TTL is the executor’s constant; doctor only READS it” and it did not — it redeclared the number. Its ttl_sec= parameter goes with it; both callers used the default.

Three things the issue got wrong

Found by reading the code rather than the issue, and recorded on it:

1. Doctor already had a pre-expiry state. It has warned on rail 17 since it was written. What it did not have was agreement: doctor warned at _days(remaining) <= 2, which truncation makes anything under 72 hours, while the banner’s window is 48. Between the two an operator saw a doctor WARNING and a cockpit calling the same attestation healthy — the exact failure this issue exists to prevent, already shipped.

2. The warning window cannot be one global number. 48 hours is right for rail 17’s 7-day TTL and is no notice at all on rail 22’s 90-day one. Doctor knew that too — it warns rail 22 at ATTESTATION_TTL_SEC // 6 and its comment reasons about being “proportional to” a fixed TTL. The window is now a field on the attestation, and rail 22’s is read from that constant rather than becoming a second opinion about it.

3. Rail 17 reads two keys, and doctor read one. The rail vetoes on enabled is False with its own sentence (“withdrawals are suspended/restricted for this account”). attestation_findings, handed the timestamp alone, reported ok · withdrawal capability attested · 6 day(s) remain for an account whose broker had frozen withdrawals. keel status never had this bug — it resolves through the executor’s own _withdrawals_enabled. The model gains REFUSED, which is not an expiry and not an absence, and all three surfaces read it.

The three surfaces

keel doctor reads the model. Every number in its rail-17 sentence now moves when the executor’s TTL does.

The cockpit grows a sticky banner, carried by the envelope rather than by one endpoint’s data. Rail 17’s expiry was on screen for three days — in a card, below the fold, on one page of eleven. null (nothing was read) stays distinct from [] (someone looked and found all clear). The banner names the remedy, because rails 17 and 22 are attested from a TTY and #781 deliberately left them outside the browser gate.

attestation.expiring fires once per attestation per window. It fired on every cycle, which on this deployment means two rails, hourly, indefinitely; an alert that repeats until it is fixed stops being read on about the third day. A window is the state paired with the expiry, so the halt alerts again after the warning did, a renewal that lapses alerts again, and a rail never attested at all — rail 22, today — alerts once rather than forever.

That last one makes notify_after_cycle write, which its docstring said it never did. The departure is deliberate and documented: one state key that no rail, report or decision reads, and tests/test_notifications.py pins the write list as exactly that key.

Verification

Mutation, not coverage: 14 defects across the model, doctor, the payload, the envelope, the client and the ledger, each proven to reach disk before the suite ran.

Three survived the first pass and are worth naming:

survivorwhy the test missed it
ledger replaces instead of merginginvisible until a third cycle reads the evicted entry — the test stopped at two
doctor keeps its own 7 * 86_400the state came from the model, so the verdict was right; every number in the sentence was still wrong (“expired 0 day(s) ago” on a 3-day TTL)
a tick claiming all clearthe assertion existed — in a file my harness did not run

All three are now killed by tests that name the mutation they exist to reject. 6617 pass, mypy clean, ruff format clean.

Still open on #793

Stage 3 of #781 (the modal and the halt button) is unrelated and untouched. The Status page’s own withdrawal_attestation card is unchanged — it is now the least authoritative of the four surfaces, and folding it into the model is a follow-up rather than part of this.

chore(release): 0.15.0 — the browser can release two halts, and attestations warn before they bite (#795)

Version bump only — eight pyproject.toml files, their == pins, and uv.lock. No code changes.

Minor, not patch. Two features have landed since v0.14.1:

  • #792 — the browser gets a real gate. Two Tier 1 halts (resume trading, clear the streak halt) can be released from the cockpit behind a typed phrase, loopback-only, and the kill switch can be engaged from anywhere. The safety invariant was rewritten in #789 to scan derived effects rather than command names, after the name scan passed on a dispatch it could not see.
  • #794 — one model of every attestation, read by keel doctor, the cockpit banner and the attestation.expiring webhook. It also closed four places where a surface disagreed with the rail it reported on, including keel doctor reporting ok for an account whose broker had frozen withdrawals.

Also in the range: #785 (one-shot ruff format across the tree, now gated in CI), #780/#784 (every reader answers on the clock it was handed), #789/#791.

Release preconditions, checked locally
gateresult
uv lock --checkclean — the lockfile moved with the bump (#424)
keel/templates/config.live.yamlmode: confirm
full suite6633 passed, 3 skipped
mypyclean, 464 files
ruff format --checkclean

After merge: Actions → Release → Run workflow, entering 0.15.0.

v0.14.1

Voir cette version sur GitHub ↗

Built from 468a749d0430. Version binds to this hash: keel --version reports keel 0.14.1+468a749d0430 [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.14.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

docs(research): the Alpaca options venue reality, marked doc-only where it is (#636) (#753)

Discovery for #636. No code, no rail change, no config — one document under docs/research/.

It does not close #636, and says so first

#636 forbids deciding from documentation alone (#412’s lesson). This session holds no Alpaca credential, so everything here is documentation, and every finding carries a mark: [DOC] quoted from Alpaca, [DOC-GAP] unanswered, [KEEL] verified in this source tree, [ARITH] derived with the arithmetic shown.

The five findings that matter

F1 — the load-bearing question is undocumented. Alpaca offers no cash accounts (“All accounts are set up as margin accounts”); keel already handles that with the multiplier == 1 proxy via max_margin_multiplier. Whether options approval survives that setting is stated nowhere. If enabling options forces the multiplier above 1, verify_cash_account raises and the adapter stops building — options would be a destructive change to the equities trading that already ships.

F5 — rail 19 already refuses an OCC symbol, and says why. SPY250127C00608000 has no hyphen, so parse_spot_product_id returns None. The rail runs in every mode, both sides, DCA included — deliberately outside LIVE_STATE_RAILS, so paper cannot skip it: there is no rehearsal path. No config widens it, and the comment says “Spot-only is this agent’s CHARTER, not an operator preference.” Options aren’t blocked by a missing feature; they’re blocked by the rail that encodes the constitution. That sizes the amendment #637 is considering.

F3 — the venue disposes of inventory by itself. ITM contracts auto-exercise at $0.01, and where buying power is short, “Alpaca will sell-out the position within 1 hour before expiry.” F2 — assignment arrives by polling only: “Options assignments are not delivered through websocket events.” It is also the first inventory event with no originating keel order.

F6 — the fees are negligible, and that is the trap. Round trip ≈ $0.084/contract; a cash-secured put’s opening leg is 4.6 bp of premium. The real cost is the bid-ask spread, which no document states. Arguing this ADR from the fee schedule would repeat exactly the error 2026-09-05-restatement-restated.md corrected for crypto — where the flat 5bp floor turned out to be 52.3bp median and the verdict went to 0 of 240.

F7 — unexecutable today regardless. A cash-secured put is collateralised at strike × 100, unfractionalisable: $1,800 for the cheapest plausible underlying against config.live.yaml’s budget_usd: 50. ~36× the deployment’s clip — decidable with no jurisprudence at all.

One note on method

The fee table is read from the PDF directly (schedule revised 2026-09-01) because an automated extraction of that same file reported OCC at $0.65 against an actual $0.025. The doc-only hazard #636 warns about reproduced itself inside this pass, so it’s recorded in the document.

What’s left

Paper accounts get Level 3 automatically, so the mechanical half (F2’s activity records, F4’s multi-leg payload, real bid-ask on touchable strikes) is verifiable free and needs no options agreement. But paper grants Level 3 to everyone, so it can never answer F1 — that needs Alpaca support in writing, or a live application. The document marks which half is which.

fix(web): the nav may not say where you are until the rows agree (#754) (#757)

Closes #754.

show() set document.title and aria-current="page", then awaited the read. For the length of the fetch every sighted signal said “you are on Balances” over Positions’ rows — a stale figure under a fresh label, the one claim _session_banner refuses to make and the balances tiles carry an as-of stamp to prevent.

The claim waits for the DOM that backs it

commitNavigation is now the single place that says “you are here” — tab title and aria-current together — and paint calls it after rebuildInto, on all three branches. A stopped view and a refused view are outcomes of this navigation too; leaving the nav on the previous route while the content reports this one is the same mismatch in different clothes.

A click is still acknowledged, with a weaker claim

Deferring aria-current removes every immediate signal from a click, which on a slow route reads as a dead link and invites a second one. data-pending is that signal, and it is deliberately not aria-current: dotted rather than solid, muted rather than foreground. A pending link that looked like the current one would restore exactly the confusion the deferral removes.

No is_navigating flag, because one already exists

aria-busy is raised in exactly one place and always was — show, plus index.html’s initial value for the first paint. Neither the 15-second poll nor an SSE repaint raises it, so the visual hangs off it directly; a second flag would be a redundant statement of one fact. Pinned by counting the assignments rather than locating one.

Content dims rather than clears — clearing costs a layout jump and claims more than is true — and takes pointer-events: none, because acting on a figure about to be replaced is the failure mode of a dimmed view left live. The bar is indeterminate: the server sends one response, not a stream, and a percentage would be a claim about something nothing counts.

Two of my own tests were vacuous, and mutation is what found them

The first show() assertion failed against a correct implementation — the new comment explains that it no longer sets aria-current, and a raw substring scan cannot tell an explanation from an assignment. A test a docstring can break is one a docstring can satisfy.

Worse: the CSS assertion was "aria-busy" in _CSS, which survived renaming the selector to #content[data-nope="true"], because the comment above the rule still said the word. Both scans now strip comments first and assert the selector.

Four mutants killed — commit-before-swap, renamed busy selector, dropped pending rule, removed keyframes.

Reduced motion needed no new rule

The blanket query — written before there was any animation to honour — already covers keel-busy, flattening it to a static bar rather than removing the signal. Its comment claimed the file had no animation; updated to say what it now does.

Checks

769 passed (tests/web/ + test_console_thinness.py), ruff clean.

Unchanged and listed in the module docstring: no test here proves the dim is perceptible, that a pending link reads as pending, or that any browser honours the query. Those are hand checks against a running keel serve.

feat(web): the deployment card — the badge’s tooltip, reachable without a pointer (#755) (#758)

Closes #755.

modeBadge writes the identifying --db/--config pair into a title, and title is a hover tooltip. There is no hover on a phone — the device #648/#656 exist for — so the half of the badge that actually identifies a deployment was unreachable exactly where the terminal is least available.

The mode word identifies nothing on its own: two paper deployments both say paper, and a live sandbox and a paper-equities profile can both say confirm.

┌──────────────────────────────────────────────┐
│  deployment   keel-live                      │
│  mode         confirm                        │
│  config       config.live-sandbox.yaml       │
│  database     keel-live.db                   │
│  origin       http://127.0.0.1:8765          │
│  ──────────────────────────────────────────  │
│  Mode and profile switching are terminal     │
│  ceremonies with a runbook. This console     │
│  reports the deployment it was pointed at    │
│  and cannot change it.                       │
└──────────────────────────────────────────────┘
A native disclosure, which is what makes it safe

The badge is now the <summary> of a <details> — keyboard-operable, touch-operable and announced, with no handler to bind and no button to build. That is what lets deploymentCard join sessionChip and paperBanner in test_neither_the_chip_nor_the_banner_builds_anything_clickable rather than be exempted from it. venueCard already uses the element the same way.

That matters more than convenience: this is the surface where a reader looks for the control, which makes it the likeliest place for one to appear later. A “switch to live” affordance arriving through a door marked accessibility is still the growth funnel the chip’s docstring refuses.

The card says so in words too, and the test pins the sentence’s content — refusing four invitation wordings — because a reference-only assertion would pass over a note reworded into a CTA.

Tags changed, layout did not

<span class="right"> and <span id="session-chip"> are <div>s now: <details> is flow content and a <span> may hold only phrasing content. Both already set their own display: inline-flex, so nothing moves. The popover is positioned out of flow — opening it must not reflow the header, which on a narrow screen would push the nav under the reader’s finger mid-tap.

title is kept, per the issue: it works for keyboard and pointer and costs nothing.

Two existing scans caught me
  • SWITCHING_NOTE was spelled with +, which test_render_contains_no_arithmetic forbids in render.js — concatenation is .concat throughout that file so the derivation-free scan can stay simple.
  • The header test pins the badge’s tag, so <summary> failed it. Updated with the reason: what it protects — the badge exists carrying that id and both classes, which modeBadge and header .mode:empty key on — is unchanged.

Registered in _CONFIG_READERS in the same commit that adds it, which is the rule that table states.

Checks

6,319 passed / 3 skipped, ruff clean. Three mutants killed: card built but never mounted, refusal reworded as an invitation, db path dropped.

Not proven here: that the popover is legible on a real phone, or that a tap opens it. Hand checks against a running keel serve.

feat(cli): keel open — the way back into a console nobody watched start (#756) (#759)

Step 3 of #756. Step 4 — the four com.keel.serve.*.plist files — is unusable without it, which is why it comes first.

$ keel open --port 8765
Opening the keel console at:

    http://127.0.0.1:8765/?token=ab12...

This address carries the running server's session token. Stopping keel revokes it.
⚠️ This writes a session token to disk. Read this part.

security.py layer 3 rests on the token being ephemeral, and serve prints the claim every run: “the token is new every run and is never written to disk.” A lockfile makes that sentence false. So it is written under exactly one condition:

The record exists only when the URL is not being shown to a human.

sys.stdout.isatty() decides.

  • Attached to a terminal — nothing is written, the posture is byte-for-byte what it was, and the sentence is still printed because it is still true.
  • Detached (launchd, a pipe) — a different sentence is printed, naming the file, its mode and keel open. Telling an operator their live credential is not on disk while writing it there is the class of false safety assurance _session_banner exists to refuse.

The argument is not that persisting a token is fine. It is that on the detached path the token is already in StandardOutPath at the daemon’s umask. The real choice is between that log line and a 0600 file in a 0700 directory, deleted on shutdown and checked for staleness before it is offered. The second is strictly less exposure, and it is the only one that makes unattended serve usable.

security.py layer 3 now says which of the two runs it is describing.

If you’d rather have uniform behaviour — always write, or never write and drop keel open — that is a one-line change and I’d rather you made it deliberately than inherited mine.

The file is a hint, never the authority

forget covers the clean stop; a SIGKILL or power cut does not. live_record re-checks the pid with signal 0 before offering anything — handing over a URL that refuses the browser reads as keel being broken rather than stopped. pid <= 0 is refused before the call, because os.kill(0, 0) signals the process group and would report a bad record as live.

Written through a temporary and renamed, so no reader sees a half-written record. Corrupt JSON reads as absent rather than raising. One file per port — #756 counts four deployments, and one shared file would make the last server to start the only reachable one. The token is never in the filename: a listing is readable by anything that can list the directory, even when the file is not.

Three absences, three sentences

No server, a server started from a terminal, and a server that was killed look identical from here. "not found" would send an operator hunting for a bug in the two cases where nothing is wrong, so each gets its own message — and the interactive one names the terminal that has the URL.

Checks

6,352 passed / 3 skipped, ruff and mypy clean. Four mutants killed: record written while a human watches, record never forgotten, liveness check skipped, file at 0644.

Not covered here: that launchd actually produces a non-tty stdout on your machine. That is step 4’s first observation.

feat(ops): four console daemons, one per profile, surviving reboot and crash (#756) (#760)

Step 4 of #756, and the last piece of the request. Only usable because #759 shipped keel open first.

profileplistportconfig + database
supervised-livecom.keel.serve.live8765config.live-sandbox.yaml + keel-live.db
paper-forwardcom.keel.serve.paperforward8766config.paperforward.yaml + keel.db
paper-hourlycom.keel.serve.paper-hourly8767config.paper-hourly.yaml + keel-paperhourly.db
paper-equitiescom.keel.serve.paper-equities8768config.paper-equities.yaml + keel-equities.db
cd ~/keel
for job in live paperforward paper-hourly paper-equities; do
  cp "com.keel.serve.$job.plist" ~/Library/LaunchAgents/
  launchctl bootstrap "gui/$(id -u)" ~/Library/LaunchAgents/"com.keel.serve.$job.plist"
done
cd ~/keel && ./.venv/bin/keel open --port 8765
The decisions worth reviewing

Ports are pinned, and that is not cosmetic. Unpinned, all four ask for 8765 and three die on a bind error — which from outside looks like a missing console rather than a clash. Asserted per file and across the table, because a collision is invisible to a per-file check.

Every daemon goes through its wrapper, never keel directly. --db defaults to keel.db, so a plist spelling its own arguments could serve the paper ledger under the live profile’s name, convincingly. The wrappers pin config to database as a pair.

RunAtLoad and KeepAlive are two promises. Reboot and crash are different failures. A file carrying only the first is the shape that looks right until something dies at 3am. The cost is stated in the plists rather than discovered: a server that cannot bind exits non-zero at once and launchd retries forever on its throttle.

Separate jobs from the four detector agents, deliberately — a console that crashed must not take the trading agent with it, and an agent mid-cycle must not restart because a browser tab died.

Two things that bit me

The double hyphen, exactly where its own note says it will. XML forbids -- inside a comment. My first draft documented the port and database options by name in the prose and all four files failed to parse. That is the hazard com.keel.live.plist’s style note already records.

My own test tripped on prose, in reverse. test_no_plist_carries_a_token forbade the substring "token" and failed on the plists’ own explanation of what the session token is. It asserts over parsed values now: no EnvironmentVariables at all, and no string shaped like a 32-byte secret.

The runbook was making a claim that is no longer true

“The token is never written to disk” held until #756 made it conditional, and test_the_section_documents_the_one_time_token pinned that exact sentence. Both now draw the distinction. A runbook promising an operator their live credential is nowhere on disk, while a file holds it, is worse than one that says which run is which.

Checks

6,394 passed / 3 skipped, ruff clean. Four mutants killed: shared port, KeepAlive removed, wrong wrapper, browser launch left on.

Not proven here, and it is the assumption everything rests on: that a launchd-started process gets a stdout that is not a tty. That is one observation against a real daemon, and it is the first thing to check after installing these — if it were false, the daemons would run but keel open would find no record.

chore(cli): one name for one idea — —no-browser on serve and open alike (#761)

Finding 2 from the #760 review, decided: --no-browser on both commands.

keel open --no-open is a sentence that argues with itself, and --no-browser is what every comparable local daemon already uses (jupyter notebook, tensorboard) — so it’s the one an operator guesses correctly without reading anything.

The old spelling still parses, and is no longer advertised

--open/--no-open survives as a hidden alias on serve. Not politeness: the four com.keel.serve.*.plist files shipped with --no-open in them and one may already sit in ~/Library/LaunchAgents. Under KeepAlive, an unknown option is not an error message anybody reads — it is a crash loop retried every ten seconds forever. The plists in this repo move to --no-browser; an installed copy keeps working either way.

default=None on the alias, so “not given” is distinguishable from “given as false”. With default=False it would silently override the real flag on every invocation that omitted it — that’s the first mutant below, and it passes every structural test in the file.

keel open does not gain the alias. Nothing ever shipped that spelling there, so there’s no script to keep working, and adding it for symmetry would preserve the contradiction this removes.

The structural tests prove the names and nothing else

Five of them read the commands’ declared options — that says the flags exist and nothing about what they do. A --no-browser that parsed and then opened a browser anyway passes all five, and on a launchd daemon at boot that is a window nobody asked for on a machine that may have no GUI session.

So the sixth runs both spellings through the real command and asserts no launch, then asserts the default still launches — otherwise the first two assertions are vacuous.

One existing call site in test_serve_command.py keeps --no-open deliberately and now says so: it exercises the alias end to end from a test that wasn’t written for it, which is the cheapest regression cover there is.

Checks

6,402 passed / 3 skipped, ruff and mypy clean. Three mutants killed, each verified to have actually applied first: alias overriding unconditionally, --no-browser parsed and ignored, alias removed outright.

docs(ops): the install block copied from the wrong directory, and skipped a wrapper (#762)

Reported from a real install of #760’s daemons: four cp: No such file or directory, then four Bootstrap failed: 5: Input/output error.

It told the operator to copy files from where they are not

The block opened cd ~/keel and copied the plists from there. These files are authored in the dev repo and deployed by copying, exactly as keel-live’s own header says of itself — so on a deployment that has not already been through this, which is every deployment the first time, there is nothing to copy.

And keel-equities had never been deployed at all

The equities detector runs paper-equities-run.sh, so nothing needed that wrapper until the console plist pointed at it. ~/keel had keel-live, keel-paper and keel-paperhourly — not the fourth. A plist naming an absent program is a KeepAlive crash loop, not a message anybody reads.

test_each_serves_its_own_deployment_through_its_own_wrapper asserts the wrapper exists in this repository, which it always does — the wrappers are authored here. Whether it exists in ~/keel is what matters at runtime, and no test can look: the deployment is outside the repo and differs per machine. What is checkable is that the documented install step copies each one, so that is what the new test asserts.

Also added: free the ports first. An interactive keel serve already on 8765 is precisely the bind failure the plists’ own comments describe, and it was the state of the machine that hit this.

Three wrong tests before a right one, all the same shape
  1. The block locator matched launchctl bootstrap — and found the detector install block earlier in the file.
  2. Anchored on com.keel.serve instead, it matched two blocks: the install and the bootout. Both anchors together select one.
  3. Then the wrapper assertion survived deleting keel-equities from the loop, because the block’s own comment explains why that wrapper matters and the scan could not tell prose from a command. It strips # comments now.

That is the third instance this session of a scan answered by its own explanation, and the third found only by mutation.

Checks

6,412 passed / 3 skipped, ruff clean. Two mutants killed: the wrapper dropped from the loop, and the block returned to copying from the deployment.

fix(cli): keel open says WHERE it looked, because that was the missing fact (#763)

Found by an install that worked. Four console daemons bootstrapped, three wrote their records, and keel open --port 8765 answered “no recorded keel server” — so a success read as a failure.

The command had been run from a source checkout, which is itself a deployment root (is_deployment_root is deliberately generous, and the tree has a keel.db and a config.yaml). So it searched the checkout’s run/ while the servers wrote to ~/keel/run/.

The message listed three explanations and the true one was not among them. It could not be — it never said where it had looked.

After
Error: no recorded keel server on port 8765.

  Looked in /Users/…/CodeGate/keel/.claude/worktrees/open-says-where/run, which is the
  deployment this directory belongs to. If your server is a different deployment, run this
  from ITS directory -- `keel open` takes the same bare-invocation path as everything else,
  so a source checkout resolves to the checkout.

  If one is running here, it was started from a terminal …

keel open takes no --db and no --config, so nothing in its signature hints that the answer depends on the working directory. The path is the hint, and --help now says so in one line.

The stale-record branch gets it too — a crashed server and a wrong directory are different problems, and an operator staring at either needs the same fact to tell them apart.

Checks

6,413 passed / 3 skipped, ruff and mypy clean. Three mutants killed: the path dropped from each refusal branch, and the help line removed. Verified by running the command from the wrong tree and reading what it prints.

One thing worth recording for #756

launchd does give keel serve a stdout that is not a tty. Three daemons, three records written. That was the single assumption the whole keel open design rests on and that nothing in CI could test.

docs(decisions): draft ADR 0005 — options, with the verdict line unsigned (#637) (#764)

Drafts #637’s record. The verdict line is unsigned — signing is striking one option, keeping the other, and setting Status. Nothing else needs editing.

#637’s sequencing is satisfied: #370 landed the equities path, and #636’s discovery closed with its findings in #753. The record is due.

Why I didn’t write the verdict

An ADR is a standing rule, and this one turns on whether a cash-secured option is admissible — gharar, the character of the premium, whether full collateralisation changes the contract. Constitution line 5 (“every attestation is human-sourced, or refused”) means no amount of documentation reading settles that, and a machine drafting the verdict would be the exact failure that line names.

The record says so in its own section rather than leaving the omission to be noticed.

Three findings decide the near term without reaching that question

Which is why option A is drafted as “not now, on operational grounds, question expressly not reached” — so signing it commits the project to no jurisprudential position it hasn’t taken.

  • F7 — the capital floor. A cash-secured put is collateralised at strike × 100, unfractionalisable: $1,800 for the cheapest plausible underlying against config.live.yaml’s budget_usd: 50. ~36× the deployment’s clip — no compliant position is executable at this size, whatever anyone concludes about permissibility.
  • F5 — rail 19 is the charter. It vetoes an OCC symbol in every mode including paper, deliberately outside LIVE_STATE_RAILS, so there is no rehearsal path. Its own comment: “Spot-only is this agent’s CHARTER, not an operator preference.”
  • F1 — the venue posture is undocumented, and the downside is asymmetric. Whether options approval survives max_margin_multiplier: 1 is stated nowhere. If it doesn’t, verify_cash_account stops the adapter building — breaking the equities path that already ships.

F3 is flagged as deserving argument rather than triage: the venue auto-exercises, liquidates within an hour of expiry when buying power is short, and reports assignment by polling only. That’s a different relationship to inventory than keel has accepted anywhere.

Reopening triggers

Four, in ADR 0002’s style, each a fact rather than a feeling. 1–3 are necessary together — deployment size, F1 answered on a live account (where a “no” closes it permanently, not provisionally), and the permissibility question answered by a human with sources.

Checks

Every citation verified against this tree — adapter.py:262, guards.py:905, config.live.yaml:82, and the research document. A script asserts every relative link in the record resolves: an ADR citing a path that moved is worse than one citing nothing, because it reads as verified.

6,424 passed / 3 skipped.

docs(decisions): sign ADR 0005 — keel does not trade options (#637) (#765)

Closes #637. The maintainer signed Option A; Status: decided.

What was decided, and what deliberately was not

keel does not trade options. Rail 19, the cash rail and the screen are unamended. The refusal rests on operational grounds — unexecutable at this deployment’s size (F7), no rehearsal path because rail 19 runs in paper too (F5), and a venue posture that may be incompatible and would take the working equities path with it (F1).

The permissibility question is expressly not reached, and Consequences now says so in the strongest terms available:

These are reasons that would hold for a non-Muslim operator running keel on the same account. Anyone citing this record as keel’s jurisprudential answer on options is citing it wrongly — trigger 3 is what would produce that answer.

Both alternatives stay in the record

Option B is not deleted. A decision record showing only the chosen branch records an outcome, not a decision — the reader two years from now needs to see what was weighed. The Decision section names B explicitly as the road not taken.

Two formatting faults caught while signing
  • The signed title wrapped onto a second line, which markdown renders as an H1 plus a stray paragraph
  • “What this record refuses to do” pointed at “Option A below” — a label that stopped existing the moment the verdict replaced the two branches

Both would have shipped as small lies about the document’s own shape.

Checks

A script asserts every relative link resolves and that all three options remain recorded. 6,424 passed / 3 skipped.

docs: stop offering a console deleted at #541, and pin the rule (#766)

keel tui went at #541 with ~10k lines of curses. The docs kept sending operators to it, in the present tense, for months. Five stale claims, and one of them was doubly false.

FileWas
README.md“the browser view and the TUI answer” — listed as a current way to read keel
docs/glossary.md“the TUI’s Help menu renders this file directly”
docs/mcp-server.mdtwo present-tense references
docs/operator-runbook.md:215“the Account menu’s update entry in the TUI console (see ‘The TUI console’ for the ceremony)”

The glossary was wrong twice over. The Help menu went at #541 and render_glossary went at #540 — so the file’s entire claim to authority (“defined HERE and nowhere else, because something renders it”) had been hollow for two releases. It now says what’s true: since #539 a term becomes an outbound link to the published copy, built from TERMS in web/static/js/docs.js — so a term added here isn’t linked until it’s added there too.

The runbook line was worse than stale: it cross-referenced a section “The TUI console” that doesn’t exist, telling an operator to use a menu that doesn’t exist. keel update has one front-end now; it had two until #541. (The browser’s “A newer build is ready” button reloads the service worker — it does not replace the binary.)

None of this was catchable. Every statement was prose, and prose has no compiler.

The rule permits the history

A retired surface may be named, provided the paragraph naming it cites the issue that retired it. Mention it, date it.

A repo that scrubbed its own deletions would be worse off than one carrying a stale line — keel update really did have two front-ends, and the runbook explaining why it now has one is doing its job. So all six remaining mentions stay; each is now dated.

Per paragraph, not per line

The first cut required the line naming the surface to cite the issue, and failed on a sentence whose wrap put ​`keel tui`, a command that no longer exists two lines below its own “Until #541”.

Prose wraps at 100 characters here, so a line boundary carries no meaning and a rule keyed to one is a rule about the reflow — the same trap that broke a runbook block locator earlier in this series when its introducing sentence was reworded. A paragraph is the unit a reader takes the claim from.

The scan also asserts its own glob still matches the four documents it names; a glob matching nothing would pass over any wording at all.

Checks

6,426 passed / 3 skipped, ruff clean. Two mutants killed: the README offering the TUI again, and the doc glob going blind.

docs(readme): drop an ordinal that counted the deleted console (#767)

Follow-up to #766. The review finding on that PR was pushed to its branch after it merged, so it never reached main — #766 merged at 761a04d, the fix was b74bdcc. This carries it over unchanged.

The finding

README.md said “There is also a fourth way to read all of this: keel mcp…”.

git log -S "fourth way to read" puts that phrase at #600, in the same sentence as “the browser view and the TUI answer” — so the TUI was one of the three it counted from. The count has been wrong since #541 deleted the command, and #766 removing and the TUI from the next clause took away the last visible trace of what it referred to.

That left a reader an ordinal with no recoverable referent — worse than before, in exactly the way #766 was about.

Dropped, not renumbered

“Third” would rot the same way the moment a surface is added or retired, and nothing in the README enumerates the set, so no number in that position can be checked by the person reading it. The sentence now just introduces keel mcp.

Also carried over

RETIRED entries must be distinctive tokens, as a note in tests/test_retired_surfaces.py. The match is \b<surface>\b over whole documents — safe for tui, useless for something like api, which would fire on every page and lead the next author to loosen the rule rather than fix a document. The note says to match the command form (keel <name>) when the name is common.

Checks

README.ar.md carries no equivalent claim, and no other a <ordinal> way survives in the README. 6,426 passed / 3 skipped, ruff clean.

docs(presentations): a walkthrough for senior engineers, structured a… (#768)

…s one order’s journey

Two files. se.md is the deck: stack, ports-and-adapters topology, the three invariants (fail-closed, ruling-as-data, Decimal-only), and a source walkthrough that follows a single hypothetical BUY through seven files in the order the code touches them — a narrative rather than a file tour. se-speaker-notes.md is the presenter’s copy: per-section timing, a marked 25-minute cut, which files to have open, and the questions this audience actually asks, with answers.

Two things are on the slides rather than hidden in the notes, because a senior room finds them anyway and claiming otherwise costs the whole talk: the broker-port migration is unfinished (_common.py still constructs CoinbaseClient directly, so the live path cannot reach another adapter), and commands/ is 23,188 lines — larger than the engine it drives. Both are also in the open-problems section, which is the point of presenting to engineers at all.

Testing leads with the failure rather than the counts: two defects produced plausible, internally consistent, wrong output for the life of the project while 2,712 tests passed. Figures used are the verifiable ones (766 test functions across 41 top-level files) rather than a round number that cannot be sourced.

Claude-Session: https://claude.ai/code/session_01NyeggYtojNXCTHeD3JHxb6

What & why
Tests-first evidence
  • Tests written first, seen failing for the right reason
Gates (all must pass)
  • uv run ruff check clean
  • uv run mypy clean
  • uv run pytest -q green
Scope check
  • This PR touches a rail or a default classification — checked means it DOES; leave checked only if true, and if so: cite the source and open the discussion BEFORE review (CONTRIBUTING.md, “Governance: rulings vs. machinery”).
  • New dependency added (needs discussion first)

docs(readme): the map omitted three documents the website publishes (#769)

The Documentation map lists nine documents. engine-docs.manifest.json in keeltrading.com mirrors nineteen — and three of the gaps are what a reader arriving at the repository would want most.

AddedWhy it matters
docs/decisions/Five standing rules, each naming what would reopen it — including 0005, signed this week, which refuses options on operational grounds and states that the permissibility question is not reached. The site publishes 0001–0004 as its own decisions section; the README mentioned none of them.
docs/architecture.mdThe site’s own summary calls it “the system as verified, not as imagined”. Unlinked here.
docs/glossary.mdThe single source for the vocabulary — and the file whose authority rests on being linked rather than restated, so a README that doesn’t link it is part of the problem it names.

The README was strictly less complete than the site built from it, which is the wrong way round: the repository is the source, the site is the mirror.

A check, not just an edit

Every docs/... target the map names is asserted to exist. A map is worth less than nothing when it points at something that moved — it reads as verified.

Checks

6,426 passed / 3 skipped, ruff clean.

docs(readme): the mark, centred, above the title — in both languages (#770)

<p align="center">
  <img src="keel/web/static/icons/keel.svg" alt="keel" width="96" height="96">
</p>

The same 743-byte vector the app ships as its icon — so the README and the running console show one mark, not two that could drift.

Why the SVG and not the PNG

It carries its own background — a teal #0c5d52 rounded square with off-white strokes — so it renders on GitHub’s light theme and its dark theme alike. A mark relying on the page behind it would vanish on one of them, which is the exact failure index.html already records for the favicon: that file is a fixed raster of the light theme, and the header inlines the geometry instead so it can wear each theme’s own colours.

Why a relative path is safe here

pyproject.toml sets readme = "README.md", so the file becomes wheel metadata — and relative image paths break wherever that metadata is rendered. I checked before choosing: release.yml publishes wheels to the GitHub Release only, with no PyPI upload step anywhere, so nothing renders this README off GitHub.

An absolute raw.githubusercontent URL would have pinned a branch name into a file that outlives branches, for no benefit.

Both READMEs

README.ar.md gets it too — one of the two isn’t “the readme”. Placed before its <div dir="rtl"> rather than inside: a centred mark is direction-neutral, and keeping it outside means the RTL context begins exactly where the Arabic text does.

Checks

Asserts the referenced file exists, the alt text is non-empty, and the mark has a <rect … fill=> of its own. 6,426 passed / 3 skipped.

docs(readme): the architecture diagram was missing two venues, and three other claims (#771)

You spotted Alpaca missing. Checking the rest of that block found three more wrong claims in the same eighteen lines.

ClaimReality
keel-broker-alpaca absentShipped — and docs/RELEASING.md puts it in the production install set: a deployment installs five wheels and that is one of them
keel-broker-kraken absentShipped (port-complete stub, #313)
~3,000 testsThe suite passed 6,000 some time ago
keel/ tree omits web/, mcp/, research/The README’s own opening paragraph advertises keel mcp and the browser console; the diagram underneath showed neither
The test count is now absent rather than approximate

~3,000 was wrong by more than double. A count in prose is a promise the suite breaks every time it grows — the honest options were an untested number or no number, and no number cannot be wrong.

And I changed a correct number to a wrong one

The block said “the 21 rails”. I made it 22, taking the highest rail number in guards.py. tests/execution/test_rail_count.py failed, and was right to: rail 15 was retired, so the rails number up to 22 and there are twenty-one. Restored to 21.

That test already derives the count from guards.check and searches every English and numeric spelling repo-wide — it exists because this exact drift happened three times. My first cut of the new file added a second rail check that disagreed with it, which is precisely how a number goes wrong: two sources of truth for one fact. Removed; that test keeps sole ownership.

What the new file checks — the part nothing owned
  • every directory under packages/ appears in the diagram
  • no package is named that doesn’t exist
  • no hardcoded test count creeps back

Four mutants killed, including dropping alpaca — the bug as reported.

Checks

6,429 passed / 3 skipped, ruff clean.

chore(release): 0.14.1 — keel open, four console daemons, and a signed ADR (#772)

Version bump only. Sixteen PRs since 0.14.0.

⚠️ One decision to make before merging: is this a patch?

This release contains three feat: commits, one of them a brand-new command (keel open, #759) plus four launchd daemons (#760). Under conventional semver that is a minor bump — 0.15.0 — not a patch.

docs/RELEASING.md states no versioning policy beyond “the input must be semver”, so I used the number you asked for. Changing it is one line here plus the workflow input, and it is far cheaper to change now than after the tag exists.

No schema migration this time

SCHEMA_VERSION is 20 at v0.14.0 and 20 now. Unlike 0.14.0 — which carried four steps and led with that fact — an upgrade here needs no keel migrate. Said explicitly because the previous notes made the opposite warning prominent, and silence would read as an omission rather than an absence.

What an operator gets

keel open — the way back into a console nobody watched start. A detached keel serve records its address in <deployment>/run/serve-<port>.json at mode 0600, deleted on shutdown; an interactive one still records nothing, so the posture security.py describes is unchanged wherever a human can read the URL.

Four com.keel.serve.*.plist daemons, one per profile on 8765–8768, RunAtLoad + KeepAlive — observed on the deployment surviving kill -9, with the record atomically replaced and a fresh token handed back.

--no-browser on both commands (#761), with --open/--no-open kept as a hidden alias — the shipped plists carry it, and under KeepAlive an unknown option is a crash loop rather than a message anyone reads.

Two console bugs found by running v0.14.0 rather than testing it: navigation showed the previous view’s figures under the new view’s label (#754), and the deployment identity was a hover tooltip — unreachable on the phone Phase B is built for (#755). Plus keel mcp’s doctor raising on an un-migrated database (#751).

And one decision

ADR 0005 is signed: keel does not trade options, refused on operational grounds, permissibility question expressly not reached. This release is what publishes it — keeltrading.com’s manifest resolves latest-release, so the record cannot be mirrored until a tag contains it.

Checks

All eight editable entries in uv.lock at 0.14.1; uv lock --check clean (release.yml runs it first). 6,430 passed / 3 skipped, ruff and mypy clean.

After merge: Actions → Release → Run workflow, entering 0.14.1 (or whatever number you settle on above), and choosing the desktop input.

v0.14.0

Voir cette version sur GitHub ↗

Built from 7770062a7236. Version binds to this hash: keel --version reports keel 0.14.0+7770062a7236 [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.14.0-py3-none-any.whl
keel versions

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

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

Configure

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

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

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

Fixes

test(scope,executor): two tests took their precondition from the machine (#742) (#743)

Closes #742.

Two tests passed in CI and failed for any contributor with a configured deployment — the people most likely to run the suite locally.

FAILED tests/commands/test_scope.py::test_attest_writes_none_when_no_current_credential_resolves
FAILED tests/execution/test_executor.py::test_confirming_replaces_a_stale_fingerprint_rather_than_carrying_it_forward
The mechanism is sharper than “the environment leaks in”

CI’s empty credential store was silently supplying a precondition one of the tests needed.

current_credential_fingerprint is imported independently at three sites — commands/scope.py, execution/guards.py (rails 20 and 22), and execution/executor.py. The executor test patches only its own. With a credential configured:

guards’ copy resolves a genuine fingerprint → it disagrees with the row’s "stale000…" → rail 20 vetoes the entry → execute never reaches the confirm step → the fingerprint stays stale and the assertion fails.

On CI the unpatched call returns None — “no evidence to disagree with” — no veto fires, and it passes. The None that made the test work came from the machine, not from the test.

The fix

Each test states its own assumption, in the pattern its own file already uses for every sibling test:

  • test_scope.py patches keel.commands.scope.current_credential_fingerprint → None, exactly as the three tests above it already patch that same target. What’s under test is what the command writes when nothing resolves; the “nothing resolves” half belongs in the fixture.
  • test_executor.py patches both sites, deliberately to different values: guards → None so rail 20 has nothing to disagree with and the entry reaches the confirm step; executor → "fresh…" so the overwrite under test is observable. The asymmetry is written into the test, because a reader would otherwise “fix” it.
A suite-wide fixture was tried first, and is the wrong shape

Recorded because the idea is tempting and the reasons it fails aren’t obvious. Three attempts, three collisions:

attemptbrokewhy
stub _from_keychain1made test_the_broken_keychain_test_is_not_vacuous vacuous — it calls that function directly to prove it reaches keyring.get_password
pin default_env_path5test_paper_equities_profile.py legitimately chdirs and writes its own .env for load_alpaca_secrets, which doesn’t use read_secret at all
wrap read_secret for identifier names12test_credential_identity.py deliberately exercises real .env/keychain resolution using exactly those names

…truncated — full description in #743.

Research & validation

docs(experiments): the 08-13 restatement restated on v0.13.3 (#737, #738) (#739)

Closes #737. Closes #738.

The verdict

0 of 240 configurations clear n ≥ 100 ∧ profit_factor > 1.0 at the taker rate this account actually pays, priced per product. Zero at the maker rate. 960 trials — five shipped signal families × 24 assets × 3 fees × 2 slippage regimes, plus the transfer check and the frequency axis. No argmax in Arm A, zero free parameters.

The 08-13 null holds across a population 2.7× larger, under a strictly more expensive cost model, on a corpus 23 days longer.

The finding, which is not that number

The engine never moved the 08-13 numbers. 565 new candles did.

The deployment DB had grown since 08-13, so a naive re-run would have absorbed that and reported it as an engine effect. A truncated-corpus control separates them:

cell08-13 printedtoday’s engine, 08-13 corpustoday’s engine, full corpusbars added
turtle XRP-USD157 / 1.223157 / 1.223 — identical157 / 1.223+0
turtle PAXG-USDT238 / 1.145238 / 1.145 — identical238 / 1.145+0
turtle FET-USD269 / 1.206269 / 1.206 — identical272 / 1.207+565
turtle CRV-USD260 / 1.017260 / 1.018267 / 1.023+565
turtle ZEC-USD268 / 1.442271 / 1.411274 / 1.700+565
pullback ZEC-USD170 / 1.044171 / 1.025174 / 1.695+565

Plus a fourth identity from Arm A: rsi_meanrev at its shipped oversold=20 reproduces §4’s anchor to four decimals — median gross 1.1251 at median n=42.

#442’s gap-through-stop exit fill, its ratchet policy, and #523’s re-derived cap are all inert for these rules. Recorded because the honest expectation was the opposite, and because a conservative-only correction that turns out to be a no-op is worth knowing before the next one is deferred on the same reasoning.

The deletion test, run in reverse on real data

§2 of 08-13 argued ZEC’s edge was tail-carried, evidencing it by deleting three trades. Adding three moved pullback_continuation on ZEC-USD from gross 1.025 → 1.695. A profit factor a fortnight of ordinary data moves by +0.67 on 174 trades is measuring three trades, not an edge.

And per-product pricing deletes the episode anyway. ZEC is the fourth-thinnest name in the universe — 107.3bp, 21.5× the floor, on $1.08M/day. Priced at its own liquidity its gross falls to 0.889: a loss before any fee.

Corrections of record against 08-13

None of these rewrite it — it keeps its numbers and gains a forward pointer (#247).

…truncated — full description in #739.

Other changes

docs(experiments): every flat-priced record points at the cost correction (#690)

Recommended by #335’s own restatement, not done at the time.

Sixteen records report profit factors priced at the flat 5bp slippage floor. One document — the restatement itself — knows that no asset in keel’s universe reaches that floor (1.1× to 36.8×, median ~10×).

Why a pointer and not a re-run

The correction is conservative-only: real cost is higher, so a corrected profit factor can only fall, and every one of the 120 measured deltas was negative or zero. Re-running thirteen documents to move numbers that were already null spends real compute to reach the same conclusion.

What changes isn’t any document’s verdict — it’s what a reader may conclude by comparing across documents. A 0.9 in one and a 1.06 in another aren’t on the same scale when both were priced at a rate neither asset trades at, and nothing on the page said so.

Nothing is rewritten

docs/experiments is an append-only account of what was run; editing a measured number would falsify it. Every change here is an addition — git diff origin/main -- docs/experiments/ contains no removed line that isn’t part of a note being reflowed.

The index needed its own note, because the blanket one was false there

Applied uniformly, the note told docs/experiments/README.md that “the figures below are priced at the flat floor” — and the index has no figures. It now says what’s true of it: that every record dated before 2026-09-01 is flat-priced.

A note that misdescribes the page it sits on is worse than no note. I caught this by reading the rendered output rather than trusting that a uniform edit was uniformly correct.

Test first

The third assertion is the one worth having: a bare link would read as a retraction, which is a worse error than the one being corrected. The pin requires the word “verdict” within 600 characters of the link, and a mutation replacing the qualification with “These numbers are wrong.” fails it.

Tests: tests/test_experiment_cost_pointers.py — 3 tests. 3 mutants, 3 killed: a record losing its pointer; a pointer that reads as a retraction; and the discovery regex matching nothing — that last one because every assertion here passes vacuously if the scan finds no records.

Full suite 5258 passed / 3 skipped; ruff clean.

feat(coinbase): refuse a derivative-capable account, and say what that does not prove (#666) (#692)

Closes #666 (Stage 1). Stage 2 — the operator attestation — is filed as #691.

Written against what a probe of the live account actually returned, not against a guess.

The probe
get_transaction_summary → margin_rate present: True, value: null
get_portfolios          → DEFAULT 'Default', CONSUMER 'My portfolio'

margin_rate is present-and-null. It’s in the response schema for every account, so its presence signals nothing — a check keyed on presence would refuse every deployment, which is the exact failure this issue was filed to avoid.

A non-null value is plausibly a signal, and could not be verified — there’s no margin-enabled account to compare against, so that refusal branch would ship untested. A compliance gate whose refusal path has never fired against a true positive is the gate that eventually fires on the compliant case and gets disabled in anger. It’s surfaced as a warning on get_fee_summary, which already fetches that response, so visibility costs no extra request.

What is unambiguous

The portfolio list. DEFAULT and CONSUMER are the spot portfolios; INTX is the international/perpetuals one, and its presence is derivative capability on the account. verify_cash_account refuses on it, called from _build_broker beside Alpaca’s — one get_portfolios read per build.

Passing means “no contradiction found”, never proof

The docstring says so, and a test asserts it does, because a reader taking a pass as evidence has the guarantee backwards. Coinbase exposes no cash-versus-margin field for spot — every margin, borrow, leverage and liquidation field in its SDK lives in the futures, perpetuals or derivative-order types. Nothing here can issue a cash posture. #691 carries the attestation that can, on rail 17’s pattern.

⚠️ It passes on an unreadable response — the opposite of Alpaca

A property of the venues, not an inconsistency. Alpaca fails closed because multiplier is the classification: a readable answer is definitive, so silence is a distinct third state worth refusing on.

Here the check can only refute, so silence proves nothing a readable response would also have failed to prove — and failing closed would refuse a compliant deployment on a network blip while establishing nothing. A mutation making it fail closed is one of the six killed.

The pin that fired

docs/fiqh-basis.md’s open question is rewritten, and test_the_venue_boundary_premise_is_stated_not_hidden failed when this landed — which is what #672 wrote it for. It’s now been re-pointed twice, and its docstring records both moves: first from a proxy that stopped tracking what it stood for, now from a premise that genuinely changed.

Verification

…truncated — full description in #692.

fix(doctor,docs): the documented path out of a halt must actually end… (#695)

… the halt (#693, #694)

Two defects, both found by following keel’s own instructions during the paper-equities bootstrap, and both of which cost real operator time.

#693 — doctor’s rail.kill_switch finding offered keel autonomy on as the fix. That command cannot clear a kill switch; its own docstring says so. They are separate gates on purpose: autonomy decides who gets ASKED, the kill switch decides whether the agent runs at all. An operator who followed the fix line ended up in the worst of the two states — a halted agent authorised to trade unattended. The fix line now reads keel resume.

The old assertion in test_doctor.py pinned keel autonomy on, so the defect had a test defending it. It is restated here, with a comment saying what it got wrong.

#694 — the runbook’s paper-equities and paper-hourly bootstraps ran migrate, seeded rules and fetched candles, then stopped. migrate creates schema and never seeds, so a fresh database has no kill_switch row and get_state("kill_switch", default=True) fails closed. Both profiles logged skipped: kill_switch every cycle until someone noticed. Each bootstrap now names the arming step immediately after its own fetch block, deliberately OUTSIDE the copy-pasteable fence — same gate as keel autonomy on and the rail-17 release: a human at a terminal, never a scheduled job.

Tests (written first, red before green):

tests/commands/test_doctor_fix_lines.py — 2 tests: every fix line names a real command, and the kill-switch fix names keel resume specifically tests/test_bootstrap_arms_the_profile.py — 3 tests: both bootstrap sections exist, each names the arming step, and the step is NOT inside a copy-pasteable block

Mutation-verified, four mutants, all killed: fix line reverted to autonomy on; fix line naming a nonexistent command; the equities arming step deleted; the arming step folded into the fence.

Closes #693 Closes #694

Claude-Session: https://claude.ai/code/session_01NzuKAe2RVrPt9acVAWjRyL

What & why
Tests-first evidence
  • Tests written first, seen failing for the right reason
Gates (all must pass)
  • uv run ruff check clean
  • uv run mypy clean
  • uv run pytest -q green
Scope check
  • This PR touches a rail or a default classification — checked means it DOES; leave checked only if true, and if so: cite the source and open the discussion BEFORE review (CONTRIBUTING.md, “Governance: rulings vs. machinery”).
  • New dependency added (needs discussion first)

feat(research): measure what a round trip costs on a commission-free … (#697)

…venue (#371)

Alpaca Phase C’s cost-fidelity half. The PRD makes this a precondition — “no strategy claim is believed before it” (§6.3, O4) — and the ordering earned its keep: config.paper-equities.yaml ships taker_pct: 0.0, honestly labelled as awaiting this work, and priced literally that models a FREE venue.

Result: a round trip costs ~2.2bp on Alpaca equities and ~306bp on Coinbase spot, a factor of 141. keel’s crypto model is validated in passing (332bp charged vs 306bp measured, conservative by 8%, from an estimator with no knowledge of Coinbase’s fee schedule). Its equities pricing is 7.5x too high.

Two findings mattered more than the headline.

THE AGGREGATION CHOICE IS WORTH 22x, AND THE OBVIOUS ONE IS WRONG. Corwin-Schultz two-day estimates go negative constantly. Flooring each pair and averaging the survivors keeps the positive half of symmetric noise and discards the negative half, so the mean converges on E[max(X,0)] > 0 — it reports a spread that is not there, and a bigger one the more volatile the series is. Worst, therefore, exactly on the asset class this exists to price. The paper’s own procedure averages within a month and floors the MONTHLY mean; negatives then cancel inside the block. Same data, same estimator: MSFT 41.8bp -> 1.9bp. The first run of this measurement reported 43.6bp and a 4.1x ratio, and what exposed it was the implausibility of a mega-cap quoting 41.8bp, not a test. Both aggregations stay reachable, because the comparison between them IS the finding.

KEEL READS MSFT AS A THIN ASSET. Cached median daily quote volume is $186M — about 2% of MSFT’s real consolidated volume, which is approximately IEX’s market share, and the equities profile runs on the IEX feed. Every ticker sits below the model’s $500M anchor, so every one is priced as thin. The candles are fine; the statistic is being asked a question the feed cannot answer. Bounded for cost modelling, unbounded for liquidity screens and admission floors. Filed as #696.

Tests (written first, red before green):

tests/research/test_spread.py — 28 tests. The load-bearing one is test_flooring_each_pair_biases_a_quiet_series_up: a seeded random walk with NO spread at all, where the naive aggregation must read >5x the blocked one. test_the_overnight_gap_adjustment_is_applied pins §I.B — equities gap nightly and crypto does not, so an unadjusted estimator would inflate the equities side of exactly this comparison, in the direction that flatters keel’s existing prior.

Mutation-verified, 12 mutants. One survived — averaging over positive blocks only rather than all blocks — because no test had a series with both positive and negative BLOCKS; test_a_floored_block_still_counts_toward_the_average was added and kills it. One further mutant proved equivalent (sum(max(m,0)) and sum(m for m if m>0) over the same denominator) and is recorded as such rather than counted as a survivor.

…truncated — full description in #697.

feat(research): the equities DCA benchmark, read per ticker (#371) (#699)

Alpaca Phase C’s second half, and the last of #371. It follows the cost-fidelity document in that order deliberately — the PRD makes that a precondition, and a DCA sleeve priced wrong is exactly the claim it was protecting against.

THE BENCHMARK IS THE MEDIAN TICKER AT +70.62%, NOT THE POOLED SLEEVE AT +145.10%. The run’s pre-declared refutation condition — “a sleeve whose terminal value is dominated by one ticker, making ‘beat DCA’ a statement about NVDA rather than about equities” — fired. NVDA returned +449.35% and is 44.8% of the terminal sleeve. Without it the remaining four returned +69.03%, within 1.6pp of the median, which is why the median is the honest summary. Quoting the pooled figure would set a bar one exceptional name built and then credit or blame a strategy for clearing it.

THE COST ARMS BARELY DIFFER, AND THAT IS THE USEFUL PART. keel’s 7.5x equities mispricing is worth 0.18 percentage points over five years here; even the fully counterfactual crypto_regime arm (Coinbase’s 1.2%/leg on equity bars) costs 3.08pp. A DCA sleeve pays its spread 250 times, one way, on a position it never closes. This is the crypto finding reached from the other side and it is easy to get backwards: cost is levied on the SEARCH, not on the edge. Cheap execution does not make a strategy good; expensive execution makes a mediocre one impossible.

sim/portfolio_sim is deliberately NOT used, and the record says why: it iterates ONE_HOUR bars (_window_bars is history_days * 24, mirroring the live agent’s hourly account pass) and the equities profile is ONE_DAY only by configuration. Handing it daily bars labelled as hourly would produce numbers that look right and mean nothing. The accumulation loop is in the driver and calls the SHIPPED Dca rule, filling at the next bar’s open plus one-way slippage — keel’s own market-order convention since #258, with the deciding bar always strictly before the filling bar.

Tests (written first, red before green):

tests/test_dca_benchmark_is_read_per_ticker.py — 6 tests. They pin the PROSE, because that is where the damage would be done: the headline must name the median rather than the pooled figure, the concentration must be quantified BEFORE the first heading, the ex-NVDA row must survive, all three declared cost arms must still be reported, and the record must refuse to read as a recommendation. test_one_ticker_really_does_dominate_the_sleeve guards the premise — if a re-run ever balances the sleeve, these fail loudly rather than enforce a caveat that no longer applies.

Mutation-verified, 5 mutants, all killed. One initially survived: gutting the concentration sentence while the table’s “44.8%” column kept the pin green. The pin now requires the figure in the opening, since a reader who takes the headline and stops is exactly who the caveat is for.

Closes #371

Claude-Session: https://claude.ai/code/session_01NzuKAe2RVrPt9acVAWjRyL

…truncated — full description in #699.

docs(evolution-plan): appendix 8 — the honest cockpit, from the Alpaca audit (#709)

Appends section 8 to docs/evolution-plan.md: the Alpaca dashboard audit’s take/refuse verdict, the beyond-parity ideas (cancel asymmetry #707, per-order honesty receipts #700, the equity series #698, the Research Hub #708), sprint sequencing for milestone 28, and the standing guardrails (loopback-safe, nothing waits on #648, ceremony card stays Phase B, Fields-only wire, zero client-side math).

Markdown is canonical; the PDF edition regenerates on the next docs pass.

feat(data): record which feed a cached series came from (#696, part 1… (#710)

… of 2)

Schema v17. The provenance half of #696, landing BEFORE the gate that reads it — a gate built on a feed inferred at read time would encode the bug it exists to fix.

THE BUG. keel’s liquidity statistic is median(volume * close) over cached candles, and every threshold keyed on it was calibrated against venue-reported volume. That is coherent on a crypto exchange: Coinbase’s own volume IS the scale the floor was chosen against. It is not coherent on Alpaca’s IEX feed, which reports one US equity exchange’s own executions. The cost-fidelity run measured MSFT — one of the most liquid securities in the world — cached at $186M/day against a model anchored at $500M, and priced it as a thin asset. Nothing recorded which feed produced those bars, so the answer was whatever broker.data_feed happened to be loaded when someone later read the series.

WHAT LANDS HERE.

candle_series_feed, keyed on (product_id, granularity, feed) — NOT a mutable column on candles. A series fetched under both feeds records BOTH rows, because that is what happened; a single overwritable column would let the most recent fetch erase the fact that most of the bars came from somewhere narrower, and a mixed series is exactly the case a reader most needs warning about.

NO BACKFILL, and that is the point rather than a shortcut. Seeding rows from the current broker.data_feed would manufacture precisely the claim the table exists to make checkable, for years of bars that may have come from elsewhere. An empty table means “unrecorded”, which is TRUE of every pre-existing series and is a different statement from “consolidated” — reports_consolidated_volume returns None for the first and False/True for the second, and callers must not conflate the absence of evidence with evidence.

keel/data/feed_scope.py carries the design, whose core is an ASYMMETRY: a single-venue feed’s volume is a LOWER BOUND on consolidated volume, never an upper one. Volume at or above the floor on a partial feed is therefore a CONCLUSIVE pass — if a name traded that much on one venue alone it necessarily traded at least that much in total — while volume below the floor licenses no claim either way and must be refused as unmeasured rather than reported as thin. That is what lets this be honest without encoding any venue’s market share: the bound holds for any share below 100%, so nothing here has a percentage in it that market structure could invalidate. test_the_bound_needs_no_market_share pins that no figure leaks into executable code, because the one thing that must never happen is someone scaling a volume statistic by an assumed share.

An unrecognised feed id reads as PARTIAL, which costs a conclusive pass on a consolidated feed nobody declared and never grants one on a narrow feed nobody declared.

…truncated — full description in #710.

feat(compliance): the asymmetric lower-bound liquidity gate (#696, pa… (#711)

…rt 2 of 2)

Part 1 recorded WHICH feed a series came from. This is what reads it.

THE GATE. Venue volume is a LOWER BOUND on consolidated volume, never an upper one, so the two directions are not equally informative and the gate treats them differently:

  • at or above the floor, on ANY feed -> CONCLUSIVE PASS. A name that traded that much on one venue alone necessarily traded at least that much in total. A gate that refused here would ban MSFT and AAPL for thinness they do not have, and would turn a data-vendor pricing tier into a prerequisite for running the engine at all.
  • below the floor, on a partial feed -> NOT A VERDICT. Equally consistent with a thin asset and with a liquid one that barely trades HERE. Refused as liquidity_unmeasured, naming the feed and both dollar figures, and saying consolidated volume is UNMEASURED rather than low. A test asserts the line never contains “illiquid” or “too thin”: the refusal must not assert the half it cannot see.
  • below the floor, on a consolidated feed -> unchanged. The crypto path is untouched, because Coinbase’s own volume IS the scale the floor was calibrated against.
  • below the floor, scope UNRECORDED -> unchanged verdict, annotated. Every series cached before v17 is unrecorded, and treating unrecorded as partial would refuse the entire existing universe over metadata nobody wrote down. It self-heals as series are re-fetched.

market_facts reads ONE_DAY provenance specifically — that is the granularity the statistic medians, so an hourly series on another feed says nothing about it.

doctor gains data.feed_scope, WARN and never FAIL: a free single-venue tier is a legitimate configuration, and the bound admits its liquid names honestly. What is not legitimate is not knowing, and the screen only speaks for a candidate somebody is actively screening while slippage_for_quote_volume prices off the same number every cycle in silence. Partial and unrecorded series are reported SEPARATELY: one should be re-fetched under a different feed, the other may already be consolidated.

The cost-fidelity record is amended rather than rewritten (docs/experiments is append-only). The amendment retracts two claims that were asserted rather than measured — “roughly 2% … approximately IEX’s share” (IEX publishes ~3.8% for Q2 2026, so the figure was simply wrong) and the “~50x” understatement — notes that no number in the document depended on either, and records that what shipped is better than what that section proposed.

Tests (written first, red before green): 27 across three files. tests/compliance/test_liquidity_lower_bound.py — 11 tests/compliance/test_market_facts_carries_feed_scope.py — 6 tests/commands/test_doctor_feed_scope.py — 8 (+2 existing)

…truncated — full description in #711.

feat(compliance): rail 22 — the cash posture no venue will affirm (#6… (#712)

…91, part 1 of 2)

Stage 2 of #666. Schema v18.

WHAT STAGE 1 ESTABLISHED, AND WHY IT CANNOT BE FIXED WITH ANOTHER READ. Coinbase exposes no cash-versus-margin field for spot. The 2026-09-02 probe found margin_rate present in every account’s response schema and carrying null — so a check keyed on its presence would refuse every deployment — and portfolios DEFAULT/CONSUMER with no INTX. Every margin, borrow, leverage and liquidation field in the SDK lives in the futures or perpetuals types. The adapter check therefore REFUTES and never issues: an INTX portfolio proves derivatives are available, its absence proves nothing.

That residual is the same shape as rail 17’s qabd. Silence is not evidence of possession, and silence is not evidence of a cash account. What closes it is a human who knows their own account saying so, on the record, with the venue able to contradict them.

TWO DESIGN CHOICES DIFFER FROM VenueTradeScope, AND BOTH FOLLOW FROM THAT:

  • No CONFIRMED state, and a test pins its absence. Trade scope earns one because the venue re-proves it on every accepted placement. NOTHING can ever prove a spot account is cash-only, so CONFIRMED would be a state nothing is entitled to write — and an unreachable state is one a later reader eventually writes anyway.
  • A TTL, where trade scope has none. Trade scope needs no clock precisely because the venue re-confirms it continuously. This record has NO observation channel, which is VenueSubscription’s situation, and there a due date is the only thing between a lapsed claim and a live spend. 90 days, not rail 17’s 7: an account does not silently acquire margin, and a prompt that fires weekly gets automated, which is how an attestation stops meaning anything. The due date is STORED, not derived, so changing the TTL cannot retroactively expire a claim a human made under the window in force when they made it.

Rail 22 vetoes new ENTRIES and nothing else — exits, stop rolls, cancels and DCA exits are untouched, because a rail that blocked an exit over a fact about the ACCOUNT would strand a position that wanted out. It is in LIVE_STATE_RAILS, so paper skips it AND reports the skip.

FOUR distinct veto messages, because there is no venue read to fall back on and the message IS the interface: never-attested, expired, attested-margin (which calls for a change to the ACCOUNT, not a re-run of the command), and a DIFFERENT credential — never described as “never attested”, which is #624’s exact mistake.

keel posture attest --spot-cash is TTY-gated and declared in keel/capabilities.py; --margin-enabled is ungated, because it only ever reduces capability — keel autonomy’s asymmetry. Recording margin is better than refusing to write: absent reads as “nobody has attested”, which is a less useful fact and invites the same person to be asked again next week.

…truncated — full description in #712.

docs(fiqh): the cash posture residual is recorded, not merely acknowl… (#713)

…edged (#691, part 2 of 2)

Closes #691. The last two acceptance boxes: keel brokers list and the docs/fiqh-basis.md open question.

THE PIN DID NOT FAIL WHEN IT SHOULD HAVE, AND THAT IS THE FINDING. test_the_unprovable_half_of_the_cash_posture_is_stated_not_hidden promised in its own docstring that “closing that needs a human attestation, not another adapter read — and if one is ever built, this fails again”. #691 built one. The pin passed, and the issue’s own acceptance criterion predicted a failure that never came.

Nothing in the pin observed the attestation, so it repeated its FIRST failure mode exactly: that form pinned _sell_base_size’s docstring as a proxy and held while the paragraph it guarded went false. A promise in a docstring is not a pin, and prose that matches is not prose that is true.

So this re-pointing reaches for the MECHANISM rather than for sentences. Four assertions that each go false if something real is removed:

  • CashPostureState must carry no CONFIRMED — adding one would mean keel believes something can AFFIRM a spot cash account, which is the claim the doc spends a section refuting.
  • ATTESTATION_TTL_SEC > 0 — without expiry, “the operator states it and keel records it” degrades to “keel remembers what someone said once”.
  • cash_posture must appear in guards.py — with no rail reading the record, “an unattested posture vetoes live entries” is a sentence about nothing.
  • the stale “a human attestation this repository has not yet built” must be GONE — the specific prose regression, caught by string rather than hoped about.

THE DOC. Two passages were wrong on their facts, not merely dated: one said keel “has no cash-account posture check on Coinbase”, which #666 added, and the other said the attestation was unbuilt. Both are corrected, and the section now states the three load-bearing properties (no confirmable state, a 90-day expiry, a claim bound to a credential) followed by the sentence to keep if only one survives: keel still cannot verify this, and neither can the venue. What changed is that the trust is now dated, revocable and written down, rather than assumed.

THE BLOCK. keel brokers list gains a THIRD block after declarations and readiness, which follows this codebase’s own argument rather than a preference: venue_readiness renders separately because merging two questions “would re-blur exactly the distinction #233 exists to draw”. Posture is a third question — readiness asks whether this CREDENTIAL may trade, posture asks whether this ACCOUNT can borrow. It ends on its own honesty line, and a test forbids the words “verified”, “confirmed”, “checked” and “proven” anywhere in it: every other readiness surface reports something it checked, and this one cannot.

Read-only and best-effort, _readiness_rows’ discipline — a display command must not create a database, and a pre-v18 file renders an empty block rather than failing.

…truncated — full description in #713.

Feat 698 equity points (#714)

What & why
Tests-first evidence
  • Tests written first, seen failing for the right reason
Gates (all must pass)
  • uv run ruff check clean
  • uv run mypy clean
  • uv run pytest -q green
Scope check
  • This PR touches a rail or a default classification — checked means it DOES; leave checked only if true, and if so: cite the source and open the discussion BEFORE review (CONTRIBUTING.md, “Governance: rulings vs. machinery”).
  • New dependency added (needs discussion first)

feat(web): status tabs and the rule that placed each order (#700) (#716)

Most of #700 was already shipped. The issue’s “Context (verified)” says there is no /api/orders endpoint and no Orders view; both have existed since #659, along with expected-vs-actual fill, the divergence and whether it went against keel, fee with a charged/modelled flag, the placement record, venue_order_id, and #626’s submit-time book. What was actually missing was two things, and this is those two.

THE STATUS FILTER. gather_orders(status=), applied in the same pass as the scope so get_orders() is still called with no arguments at all — the load-bearing pin in that function is unchanged, and its reason was always about MODE rather than about filtering in general. Server-side because a client filtering the page it received under ?limit= would be filtering fifty rows and presenting the result as every canceled order: the same argument that already puts ?sort= here.

An unknown status is applied, not refused. ?sort=’s columns are a closed set this code declares, so an unknown one is a client bug worth a 400; a status is written by whatever placed the order, and _ORDER_STATUS_STATES already carries cancelled beside canceled because spellings differ. A refusal would reject a status some book genuinely holds. An unmatched filter comes back empty saying which emptiness it is, which shows a typo as plainly as a 400 and cannot be wrong about a real word.

Three new report fields, each because a number already on the page would otherwise be quietly wrong:

  • statuses — every status in the WHOLE book, which is what a tab bar is built from. Scoped, the Canceled tab would disappear on a quiet day and return tomorrow, and a control that comes and goes is worse than one that is sometimes empty. Same reasoning as modes.
  • filtered_count — scoped_count keeps its meaning (rows in the WINDOW), so a filtered view needs its own denominator. Without it “2 of 3 filled” counts rows from other tabs: every number true, the sentence false.
  • empty_reason: "status" — a fourth way to render zero rows, and a fourth sentence. Reusing the empty-book wording would tell a reader keel has never traded on a deployment whose book is full. Checked widest-cause-first, so an empty book is never blamed on the open tab.

The note is a FIXED string that does not echo the requested status. ?status= is caller-controlled text; it crosses in status, which the client places as a value.

THE RULE THAT PLACED IT. rule_id alone is a foreign key — a number a reader cannot act on. The name is rules.kind, the same string build_rule_track_record calls rule_name, so the Orders table and the track-record table cannot name one rule two ways. Resolved from ONE read of the rules table, passed down as a map: a per-row lookup would be one query per order against a book capped at MAX_ORDERS_LIMIT, and would be invisible on any fixture small enough to read, so there is a pin on the read count.

…truncated — full description in #716.

feat(web): the Positions view — marked at the rails’ own price (#701… (#717)

…, items 1-4)

keel status showed a tranche’s id, product, rule, qty, entry price and bracket, and web/payload.py::_position_payload explained in its own docstring why it showed no P&L: “OpenPositionStatus carries neither, so emitting one would mean this layer multiplied qty by entry_price… the fix, if it is wanted, is upstream.” This is that fix, upstream, plus the endpoint and view that read it.

THE MARK IS THE RAILS’ MARK, AND THAT IS THE WHOLE POINT. agent._mark_to_market_parts values a holding at repo.get_candles(product, finest)[-1].close; gather_positions makes the same read through the same _finest_granularity. A positions page quoting a different current price would be a second answer to “what is this worth”, and since the first answer moved rail 11’s drawdown scalars, the page would be the wrong one.

That is asserted across the module boundary rather than claimed. The two readers do NOT share a source for QUANTITY: the agent counts inventory from the filled-orders log — never from positions, deliberately, after the phantom-drawdown bug that taught it — and this report reads the tranche ledger. They share only the mark. So the reconciliation test is what says the two ledgers agree about what is held, and it fails the day they drift.

WHAT IS ABSENT STAYS ABSENT. No cached candle means no mark, and no mark means no market value, no unrealized and no stop distance — never zero. A zero market value renders a held position as a total loss, which is the most alarming thing this page could say and it would be saying it about missing data. initial_stop NULL is “not on this row” (a DCA leg, a pre-v12 tranche), so its distance is absent too: measured against a substituted zero it would read as a position comfortably clear of a stop it does not have.

Signed where the sign means something, and nowhere else. unrealized and stop_distance carry verdicts; a market value does not, because an account is not good for being worth something and a glyph on every balance hides the one figure that matters. A negative stop distance means the tranche is trading THROUGH its protection — keel is cash-spot and long-only, so a stop always sits below the mark and the sign has one meaning. stop_distance_pct crosses as a raw fraction with no %, the posture ratio already documents for the drawdown scalars.

THE FRESHNESS CHIP IS THE ENTRY GATE’S VERDICT, not a data age. missing/behind/ unconfirmed are entry_bar_ready’s own words for why the agent would refuse to open here. freshness.assess tolerates the normal forming-bar lag; entry_bar_ready refuses a one-bar-late finer series because that lag is exactly what produces a duplicate real-money order. Showing the softer number would tell a reader the feed is fine while the engine’s own gate is refusing it — and this chip is the most common answer to “why has nothing happened”, which is why it sits beside the money rather than under a disclosure.

…truncated — full description in #717.

feat(web): the Balances view, from what a cycle recorded (#702) (#720)

#702 specified “a live adapter read”. That was refused, deliberately and on the record.

keel serve is a loopback reader over SQLite with no credentials, no broker handle and no outbound network — gather_status says “no broker, no network”, and even the Venues screen reads adapter declarations with “no broker handle, no network, no config, no credentials”. A balances page is the obvious place to break that line, and breaking it would put venue credentials into the one process a browser can reach and hand an operator’s rate limit to every tab left open on a view that re-polls every 15 seconds (main.js’s POLL_MS). Hundreds of REST calls an hour, during trading windows, for a number nobody asked to refresh.

What replaced it is better rather than merely safer. Cash comes from equity_points (#698): the figure the agent READ AND SIZED AGAINST when it last evaluated the rails, stamped with when it read it. A fresher number the engine never saw explains nothing about why it did what it did.

READ THROUGH THE MODE, ALWAYS. equity_points holds paper and live rows in one database, so the newest row FOR THE MODE IN FORCE is the answer — the newest row of any mode would show a $10,000 synthetic balance on a live deployment holding $250. paper_cash_usdc is withheld entirely in live mode even though the key survives a paper->live flip: a synthetic balance beside real money is the most confusing thing this page could show. An unstamped mode, before the first cycle, yields no cash rather than a guess about which account to read.

WHAT IS NOT RECORDED IS NAMED. #702 was written around settled-versus-total cash, and that distinction is not in the database: equity_points.cash comes from _fetch_available_quote, which reads Balance.available and stops, summed across every currency in play — so the Alpaca adapter’s T+1 clamp reaches storage with the distinction already flattened out of it. The tiles render as absent with UNRECORDED IN CYCLE SNAPSHOT beside them, rather than showing the available figure under a “settled” label that would answer the question this page exists to ask honestly. #719 records the pair; the report and payload were shaped so that lands as a value change, not a shape change.

A PARTIAL SUM IS THE DANGEROUS SHAPE. An asset whose tranches are not all priced reports its QUANTITY and no value. Summing over the priced subset would look like a total and be a smaller one — and a holding rendered as worth less than it is, on the page an operator checks to see what they have, is the failure worth engineering against. What is held is a fact; what it is worth was not observed.

…truncated — full description in #720.

feat(web): one chronology over four stores, with an injection-safe au… (#722)

…dit export (#703)

Four stores record what keel has done and none of them knew about the others: the engine’s JSONL log, the orders book, the transactions ledger, and the attestation tables. Merging them is only an improvement if the merge keeps them APART — otherwise it is four tables with their labels removed.

So provenance is a first-class field from a closed vocabulary, decided once in the service and never inferred from a row’s shape by a renderer. A live fill is venue-reported. A paper fill is simulated — the paper trader wrote it with no venue involved, and one word covering both would put synthetic and real evidence in one bucket, which is the one thing four separate tables at least never did. A transactions row is imported-ledger: the venue produced the CSV, but nothing verified the line on the way in, and that is not the same claim as a venue report. An attestation is human-attested, and the name of whoever swore to it is in the summary, because “who signed this” is the first question an auditor asks of one. A cycle is engine-log: the agent’s own account, which nothing outside the process confirmed and nobody signed.

simulated is the only provenance styled WARN. Not because synthetic evidence is worse — an imported ledger line is not worse evidence than a venue report, it is different — but because a paper fill sitting in a chronology beside real ones is the single row a reader must not skim past.

THE EXPORT IS A SECURITY SURFACE, AND WAS TREATED AS ONE FROM THE FIRST LINE. This file is meant to be opened in Excel or Google Sheets by an auditor or a tax preparer, and both EXECUTE a cell whose text begins =, +, -, @, tab or carriage return. Several columns carry text keel did not write: a transaction’s notes and coinbase_id come out of a venue’s own CSV export, an attestation’s source and attested_by were typed by a person. csv_safe applies OWASP’s defence — prefix with a single quote so the cell is inert text — to EVERY cell rather than to a list of the risky ones, because a maintained list of which columns are attacker-influenced is exactly the thing that rots.

Writing the end-to-end test for that found my own example was wrong: a hostile notes value is diluted by accident, because summary prefixes it with “deposit USD — ” and the cell no longer starts with a trigger. The real path is a field that lands at the START of a cell by itself, so the test drives a hostile coinbase_id into reference. Relying on the accidental prefix is exactly what applying the defence everywhere avoids.

…truncated — full description in #722.

fix(web): the CSV export sent a body on HEAD, dropped connections, an… (#723)

…d truncated itself (#703)

Review findings on #703, which merged before these landed. Three of them are on the export route, and all three are the kind static analysis cannot see.

A HEAD RETURNED THE WHOLE FILE. _send, _send_json and _serve_static all end their write with if self.command != "HEAD". _send_csv did not, and do_HEAD delegates to do_GET — so HEAD /api/timeline/export.csv answered with the header block plus 468 bytes of CSV. On a keep-alive connection those bytes are framed as the NEXT response: a same-origin response desync, not merely a wasted transfer, and a violation of RFC 9110 §9.3.2.

The first test written for this passed against the bug. http.client knows a HEAD carries no body and never reads one, so it reports an empty body whether or not the server sent bytes — the test now reads a raw socket, and says why in its docstring.

THE ROUTE DROPPED CONNECTIONS. respond is what normally guarantees this server never answers a GET by raising: ApiRefusal becomes a 400, anything else a 500 envelope. The CSV branch does not go through it, so ?limit=abc propagated out of the handler — the client saw a reset connection with no response, and a traceback of absolute source paths reached the stderr log_message is overridden to keep quiet. A first-run machine with no config.yaml did the same, where /api/timeline answers 200 with engine: "stopped". Wrapped now, answering the JSON envelope: the failure is not a file, and a browser handed a truncated download learns nothing.

THE EXPORT WAS SILENTLY TRUNCATED. It inherited the paged route’s ?limit= — default 200, ceiling 2000 — and the file said nothing about it. A 5,000-event deployment exported its most recent 200 rows, to be handed to an auditor or a tax preparer as a complete record. The cap exists because the console POLLS the JSON route every 15 seconds; an export is a deliberate download, requested once. export_rows reads the whole scope, and the scope — the operator’s own choice of today/7d/all — is what bounds it.

A SORT COLUMN THAT ORDERED NOTHING. sortable=("ts", ...) while the payload emits at, so ?sort=ts was accepted, echoed back as applied, and left every row where it was — the exact failure refusing an unknown column exists to prevent. The pin for this rot already existed at test_api.py:526 and covered two routes; /api/timeline was simply not in it, and is now.

…truncated — full description in #723.

feat(web): publish the research record, rejected trials included (#708) (#724)

View 1 of the Research Hub: the /research route, the /api/research/trials endpoint, and the trials-ledger table the rest of the hub hangs off.

keel’s trials ledger has been append-only and hash-chained since spec §4. What was missing was the reading of it — the record lived in a file in the repo and reached nobody. This puts it on a screen, in ledger order, with the REJECTED trials in the same table as the selected ones. That is the point of the view: the rejected rows are what make the selected one evidence rather than a claim, and no broker, platform or competitor publishes theirs.

THE STRATHERN RAIL, HELD IN THREE PLACES

  • the service — gather_trials returns rows in ledger order and offers no ordering of any kind;
  • the endpoint — /api/research/trials declares collection="" and sortable=(). Not “no performance columns”: NONE. _sort_request refuses a column a route does not declare, so every ?sort= is a 400 and ledger order is the only order this endpoint has. Declaring the harmless ones (at, rule) was the tempting middle road and is refused — a sortable collection invites the next column, and the next column is the one that ranks;
  • the view — researchView(data) takes neither sort nor onSort, unlike every other table view in render.js, and not one column declares a key.

An AST scan over research_record.py mirrors the one test_research_front_door.py runs over the CLI group.

THE CHAIN BADGE HAS THREE STATES, NOT TWO

chain_intact=False means “a row was edited” and also “there was no file to read”, and those are opposite facts. Styling the second as a break reports a tamper that never happened; styling it as verified asserts a check that never ran. So the badge is GOOD only after verify_chain returned no errors over rows that were actually read, and a missing ledger reads UNKNOWN. A tampered row turns it BAD and chain_errors names which rows broke — a red light with nothing to look at is not evidence.

ALSO IN HERE: A RED TEST ON MAIN

test_every_declared_sort_column_is_a_column_the_rows_actually_have has been failing since #703 added /api/timeline to it. The seeding writes positions and rules; the timeline reads orders, transactions, attestations and the log, and reads positions not at all — so the pin asserted on rows nothing had written. _seed_orders fixes it, and the pin now genuinely checks at, kind, provenance, source and product_id against real timeline rows.

Notes on two decisions a reader will ask about:

…truncated — full description in #724.

feat(web): publish what a fill is assumed to cost, per product (#708) (#725)

The Slippage Universe, as a second section of /research. Every experiment document in this repository prices its fills at 5bp — the FLOOR of slippage_for_quote_volume, reached only at the model’s $500M/day anchor. Priced from each asset’s own cached liquidity, almost nothing reaches it. #686 established that and it has existed since as a sentence in a markdown file; this puts the per-product table on a screen, derived rather than restated, so it cannot go stale the way a hard-coded “0 of 24” would the first time the allowlist changed.

SEQUENCE CHANGED: THIS IS VIEW 4, LANDING WHERE VIEW 2 WAS PLANNED

The Evidence Matrix is blocked and the reason is recorded on #708. Its scope note says the grid is “assembled from ledger rows … a thin assembly layer, not a new store”. It is not: the ledger’s 93 rows are aggregate findings with hand-written per-experiment summary keys (cells_above_pf_1, pf_zero_fee_turtle_btc), there is no (rule, asset) -> (pf, n_trades) schema to assemble, and no regime field exists at all — “regime” appears twice in the file, both times inside English prose. #686’s 240 per-cell rows were written to docs/experiments/_out/, which is untracked and absent. Building that view needs a tracked per-cell artifact first; assembling it from what is there would mean inventing cells.

EVERY NUMBER ON THIS PAGE IS AN ASSUMPTION, AND THE PAYLOAD SAYS SO

#708’s scope calls these figures “per-product measured bp”. They are not measured. slippage_for_quote_volume opens by saying so — keel stores no book snapshots and no realised spreads, so liquidity is proxied by the one statistic it does compute — and sim/report.py carries the same correction in bold on its own table. So basis crosses as a field rather than living in a comment nobody ships, and the floor, cap and anchor travel with the rows for the reason that file gives: “a number whose assumptions cannot be recovered is not evidence.” Everything needed to recompute any row is on the page.

TWO FLAGS THAT BOTH WARN, BECAUSE BOTH UNDERSTATE

A fallback row got the model’s CHEAPEST rate for having no cached history; a capped row hit the thin-end bound before its own liquidity was allowed to decide. Neither is an error and both are flattering, which is exactly what a reader skimming a cost table for big numbers would miss.

at_floor_count excludes fallback rows, and that exclusion is the honesty of the headline: a fallback row IS priced at the floor numerically, so counting it would turn missing data into the best case. priced_count is the denominator it is honestly out of.

BORROWED DEFINITIONS, NO FIFTH ONE

…truncated — full description in #725.

feat(web): publish the gauntlet as it was run, not as recomputed (#708) (#727)

View 3 of the Research Hub, re-scoped from “compute the gauntlet” to “show the gauntlet as it was run” — because every computed version was measured as impossible or unaffordable first.

The exhibit

Six of the ledger’s 93 trials went through the gauntlet. Three produced a PBO with the seed that makes it reproducible; three record that it could not run at all. None passed the gate.

trialPBOrangateseed
476-optuna-turtle_breakout0.7yesno476
476-optuna-rsi_meanrev0.571…yesno476
476-optuna-pullback_continuation0.842…yesno476
cusum-event-first-measurement-2026-09-01—nono—
triple-barrier-first-measurement-2026-09—nono—
per-product-slippage-restatement-2026-09—nono—

Verified against the real ledger before the view was written: total=93 recorded=6 available=3 not_run=87 passed=0.

Why this view computes nothing

The web UI is a viewer of recorded facts, not a batch computation engine. All four components #708 scopes for this view are computation, and each was measured:

  • DSR is impossible, not merely expensive. keel trials deflate takes --sharpe as a required operator input, because the ledger stores no observed annualised Sharpe per trial. Computing it here would mean synthesising the input.
  • PBO raises over the ledger as a whole — build_matrix: columns are not synchronous: found lengths [1819, 1828]. It is defined only within a session whose trials share a bar count, and a page cannot pick that scope without inventing an operator’s decision. Where it runs: 11.9 s, 12.9 s, 14.3 s for the three eligible sessions, against a 15 s poll.
  • Monte Carlo and the DCA benchmark both run a backtest.

Timings and constraints are recorded on #708; #726 is the engine issue for persisting the distributions the CLI currently prints and discards.

Three states, not two

Ran (pbo_available: 1), attempted-and-refused (pbo_available: 0 — every column series-missing, or the grid too thin for CSCV), and never run. The third is a count, not 87 empty rows: a table of blanks would present the gauntlet as having covered the whole record. recorded_count is likewise the honest denominator for gate_passed_count.

Two styling calls worth arguing with

A failed gate is neutral, never bad. Every recorded run failed, and that is the system working as designed — a table of red rows would read as a list of things that went wrong rather than as evidence of a discipline being applied.

…truncated — full description in #727.

feat(data): schema v20 — provenance, attestation windows, balances, audit chain (#728)

The schema half of the batched engine sprint for #715, #718, #719 and #721. One version bump, no writers.

Landing the DDL on its own keeps the database atomically v19 or v20 rather than half-migrated across a PR series, and lets the no-backfill posture be reviewed by itself.

What it adds
issueadded
#715orders.quote_provenance, orders.client_order_id
#718attest_due_ts on asset_attestations and instrument_attestations
#719cycle_balances — one row per (cycle, currency), available/total as TEXT Decimals
#721audit_events — the append-only hash chain settled in this comment

cycle_balances is per CURRENCY and not one scalar pair, because folding currencies into a single figure adds them 1:1 — the no-FX bound agent._mark_to_market_parts already states it will not cross.

No backfill, and it is tested

Every new column is NULL on every row written before v20, and NULL means “not recorded” — never zero, never a provenance inferred after the fact from limit_price or the submit book. A test seeds a real v19 orders row and an attestation row, migrates, and asserts NULL across the board. A mutant that adds UPDATE orders SET client_order_id = 'backfilled' fails it.

⚠️ audit_events.ts is INTEGER, deliberately against the issue text

#721’s comment specifies ts TEXT. That is wrong for this schema, and the DDL says so where a reader will meet it: every other timestamp here is INTEGER epoch seconds, and equity_points.ts already documents why — TEXT orders an epoch lexically, so "9999999999" sorts before "10000000000" and a ts >= ? range read (exactly how an audit trail gets queried) silently returns the wrong rows once the digit count changes.

Following the issue literally would have walked straight into the one bug that comment exists to prevent.

Collateral, found by the suite

Repository.get_instrument_attestation is SELECT * → dict(row), so a new nullable column mechanically changes its return shape. The round-trip test asserted exact dict equality and broke — not because behaviour changed, but because a passthrough getter now honestly reflects the column.

Verification
  • 5,899 passed / 3 skipped; mypy clean on 430 files; ruff clean; scripts/migration_smoke.py reaches schema 20 from both a fresh DB and one downgraded to v1.
  • Eight mutants killed: version not bumped, migration unregistered, either ALTER dropped, either new table dropped, the per-column idempotence guard removed, and a backfill sneaking in.

Schema only — nothing in this release writes or reads any of it. Four follow-up PRs add the writers; commands/balances.py and commands/timeline.py keep reporting their fields as unrecorded until then.

feat(engine): record where an order’s price came from, and the id we sent (#715) (#729)

Writers for the two orders columns that landed empty in schema v20 (#728). Closes #715.

Quote provenance, from the same preview the gate reads

keel_core.quote_provenance holds four tokens and a pure provenance_of(preview). confirm.py’s four operator-facing banners are now derived from those same tokens rather than written twice, so the sentence an operator reads before approving and the value the database records cannot drift apart. No banner’s wording changed — the existing 20 gate tests pass verbatim.

executor._order_row writes it beside submit_best_bid/submit_best_ask, from the Preview it already holds. It is a property of the preview, not of the confirmation gate, so it is recorded in autonomous mode too.

Paper orders record NULL: they fill synthetically with no venue preview at all, and a fabricated provenance sharing a column with a real one would poison the measurement the column exists for — the same posture the submit-book columns already take.

The harder half: nothing could record a client order id

resolve_client_order_id mints a uuid4 per placement attempt inside the adapter, and PlaceResult carried only success/broker_order_id/reason — so the id actually sent to the venue was discarded at the boundary. Left alone, the column would have been NULL forever and read as an honest absence rather than an unbuilt feature.

PlaceResult now reports it; three adapters populate it on every return path including rejections and pre-flight refusals; the executor writes it through on placement. keel-broker-kraken is an unimplemented stub, so there was nothing to wire.

Recording only. No idempotency_key is now passed into place_order. A per-attempt id is a deliberate default, and changing it would change venue-side deduplication — a behaviour change wearing a recording change’s clothes.

Live-path safety

The executor diff is 14 added lines and none removed; no control-flow condition on the order path was touched. Confirmed by inspection of the diff, not just by the suite.

Where the code contradicted the plan

The token vocabulary follows the code rather than the issue. confirm.py distinguishes exactly four preview states — native, synthetic, unpriced, unreadable — and Preview.errors is not a fifth: it is an orthogonal alarm block that can accompany any of the four (a clean venue quote can still carry errors). So there is no VENUE_ERROR token.

Also un-stales the v20 DDL comments, which said “nothing writes it or reads it back” about both columns — true when written one PR ago, false now.

Verification
  • 5,934 passed / 3 skipped; mypy clean on 432 files; ruff clean across keel/, tests/ and packages/.
  • Five mutants killed: provenance not written, client id not written, either column dropped from the _ORDER_COLUMNS INSERT tuple, and PlaceResult no longer reporting the id.

…truncated — full description in #729.

feat(engine): record and report when an attestation’s window closes (#718) (#730)

Writers and a reader for the two attest_due_ts columns that landed empty in schema v20 (#728). Closes #718.

The ON CONFLICT trap, tested rather than trusted

Both upserts are INSERT … ON CONFLICT DO UPDATE SET. Adding the column to the insert list but not the update clause would mean a re-attestation silently carries the previous window forward — a compliance window that never expires, which is worse than having none.

Both clauses set it, and the test that proves it re-attests without a window and asserts the old one is cleared rather than inherited. Mutating either clause fails that test by name.

Recorded, and then actually read

keel doctor gains asset and instrument window findings. A column nothing reads is a column nobody benefits from — the mirror of #715, where a column had no writer.

A NULL window is not an expired one. Every row written before v20 has NULL, and NULL means no expiry was recorded — never “expired”, never “valid forever”. It is excluded from both the passed and approaching groups, and the mutation that folds NULL into a finding fails a named test.

Note the deliberate asymmetry with cash_posture_findings beside it, which FAILS on a NULL due date: rail 22 requires an expiry for a cash-posture claim, so absent is a refusal there. Asset and instrument attestations have no such rail and inherit a table full of legitimate NULLs, so absent is benign.

⛔ Report only — no veto

compliance/screen.py is untouched. An expired window does not refuse a screen or block an entry. Findings are WARN, never FAIL, and say so in their own detail text.

Whether a stale window should ever veto is a decision for you, not for a recording issue. For the record, the implementer’s view is that it should not: sector and backing facts don’t go stale the way a credential or cash-posture claim does, so a blanket TTL would refuse well-known assets on a clerical technicality.

Smaller things
  • --attest-due on both CLI commands takes a unix timestamp or YYYY-MM-DD, reusing insights._parse_ts — the convention keel insights journal’s --since/--until already established, rather than inventing one.
  • The first-run wizard passes nothing and records NULL. No default window is invented on an operator’s behalf.
  • Un-stales three db.py comments that said nothing writes or reads these columns — true one PR ago.
  • upsert_asset_attestation had no round-trip test at all before this (only the instrument path did); that gap is now filled.
Verification
  • 5,960 passed / 3 skipped; mypy clean on 433 files; ruff clean across keel/, tests/, packages/.
  • Six mutants killed: either ON CONFLICT clause dropping the field, the CLI ceasing to pass it, NULL windows becoming findings, and passed or approaching windows ceasing to report.
Two pre-existing gaps found, deliberately not fixed here

…truncated — full description in #730.

feat(engine): persist the per-currency balance pair a cycle observed (#719) (#733)

Writers and a reader for cycle_balances, the table that landed empty in schema v20 (#728). Closes #719, and with it the last of the three recording gaps #702’s Balances view was split around.

Rail 13’s input was not refactored, and that cost a duplicate

_fetch_available_quote reads a Balance and discards Balance.total. The obvious move is to widen it. It is also rail 13’s input, called before guards.check, and its every-failure-is-None contract decides whether an order is placed — so a sibling _fetch_quote_balance_pair was added and the original left untouched. Its 149 existing tests pass unedited.

Duplication is the right trade there, and it has a price: two functions answering one question can disagree, and if they do, the balance rail 13 gates on and the balance a cycle records quietly stop being the same number — each self-consistent, the divergence invisible from either side.

So they are pinned against each other across all eight cases: case-folding, an empty currency, a raising broker, a missing account, a None broker. Making one match currencies case-sensitively fails that pin.

The codebase already names this failure — median_daily_quote_volume’s docstring refuses a second copy of one statistic, because a sweep proposing an asset the screen then rejects is what drift looks like from outside.

One mode read, two tables

Rows are written inside equity._append_equity_point, off the same equity_state_mode lookup that stamps the equity point — not from a second call in agent.run_once, which would have read the mode twice and let one cycle answer “which mode” two different ways.

The scalar-vs-per-currency question

The record is per currency; BalancesReport.settled_cash/total_cash are scalars. They report the settlement currency’s row only, never a sum: _mark_to_market_parts states an explicit no-FX bound, and adding USD to EUR 1:1 would break it silently. Justified in both the module and field docstrings.

A paper deployment never reads a live cycle’s balances

Found by mutation: hard-coding mode="live" in the read passed the entire suite, so nothing exercised any mode but live.

A deployment that traded live and is now on paper still has live rows on disk. An unscoped read would put a real venue’s settled and total cash on a paper account’s page — on the one screen whose whole job is saying what is actually there. Now pinned.

Verification

…truncated — full description in #733.

feat(engine): an append-only hash chain for the book, and a column that stops saying NOT RECORDED (#721) (#734)

Closes #721.

#703’s activity export shipped with a row_hash column whose every cell read NOT RECORDED. This is the record that column was built to read.

The chain is over events, not over the book

orders rows are mutated — update_order writes status, fills and fees as a venue reports them, sometimes days later via execution.reconcile. A hash chained over the order row would break on every legitimate fill, and a chain that cries wolf on ordinary operation is one an operator learns to ignore. The issue named both options and said the append-only event table is the one that actually delivers tamper-evidence for a mutable book.

keel/data/audit.py chains immutable statements about the book. Each write to orders, transactions and both attestation tables appends one event in the same transaction as the row it describes. Re-importing a transaction appends rather than rewrites — two events for one coinbase_id is the record that a line arrived twice with different content.

One chain, not one per table: a per-table chain would let an event be moved between streams undetectably.

One canonical form, and a proof it did not move

The issue’s hardest constraint. keel_core.hashchain now holds the form and the walk; research/ledger.py and data/audit.py both import it, and each keeps deciding what its own rows commit to.

The move is pinned by the one test in this repo that asserts against hashes computed by a previous version of the code: the 93 git-tracked rows in docs/experiments/trials-ledger.jsonl still verify byte-for-byte. Dropping separators from the canonicaliser fails it.

Three readings, and the middle one is the point
statusmeaning
chainedan event exists and the chain vouches for it
not chainedno event was written — rows predating this, and every engine-log row (the log is a file, not a chained store). An honest gap, deliberately not a break
chain brokenthe event falls at or after the first break. The hash is shown and is not evidence

A chain proves a sequence, so past a break the sequence is unproven whatever the single row’s own hash still says. Showing those as chained would present unverified values as evidence, which is the one thing the column exists to prevent.

chain_status sits beside row_hash, never inferred from it — in the payload, in the table, and in the CSV.

Green requires something to have been checked

intact is no errors and events exist. An empty chain reports no breaks because it has nothing in it to break, so keel doctor says “audit chain empty — unverified”. That is _chain_payload’s four-state lesson from #708, on the trading side, where the store cannot be absent so there are three.

Refused

…truncated — full description in #734.

feat(web): session identity on every view, and a paper banner with nothing to click (#704) (#735)

Closes #704.

Take the spine, refuse what rides on it. Making paper-vs-live visible on every page rather than buried in settings is the one organizing idea worth borrowing from a retail broker console. What their version attaches — an “Open Live Account” button on the paper banner — is a growth funnel wrapped around real money.

The chip: profile · mode · equity state

It surrounds the existing mode badge rather than replacing it, so the badge keeps owning the mode word and its db/config tooltip, and each fact stays written by the one function that knows it.

profile names the database, and the stem is as far as it goes. ADR 0002 settles what a profile is — “the database is already one-per-profile”, which is also why equity_points has a mode column and no profile one — so there is nothing stored to read. keel.db and keel-live.db are the operator’s own names; a mapping from those to “paper”/“live” would be this console guessing which is which from a filename, on the one surface built to stop paper and live being confused.

equity_state is a Field, because it has an UNKNOWN reading that matters: equity_state_mode is written on the first mode flip, so a deployment that has never run has none — and “which account drove the drawdown scalars” being unanswered is a different fact from either answer. It reads not recorded, not an em-dash: label(None) is absent(), whose display is right in a table cell and wrong in a chip. keel · confirm · — drops the very half the banner asks the operator to verify.

One database read on an endpoint that must survive having no database

equity_state_mode lives in agent_state, and /api/config is the only endpoint every view reads — which is what makes the chip present everywhere. That route is needs_database=False because the client boots from it alone, and it still is.

The read checks the file exists before connecting, which is not a micro-optimisation: sqlite3.connect creates what it cannot find, so an unguarded read would have a read-only view bring a deployment into existence merely by being polled every 15 seconds — and every page would then report a healthy empty install rather than offering to set one up. server.ensure_schema carries the same guard, found the same way. Any failure degrades to unknown, so a first run still boots the shell.

The banner: a statement, not an alert, and nothing to click
modebanner
paperPAPER — no real money is involved (fixed wording, asserted verbatim)
confirmthe mode and the equity state — the pairing the operator is about to check against the venue’s own UI
unreadablenothing. A banner is a claim about whether real money is involved, and there is no safe default for that claim

…truncated — full description in #735.

feat(engine+web): wire the discretionary journal — CLI-only in, self-reported everywhere out (#705) (#736)

Closes #705.

The journal table has been in the schema from the beginning with no repository method and no caller anywhere in the code. Dead schema is worse than no schema: a reader assumes a declared table is a used one.

The one record here that nothing can check

Everything else keel keeps is a machine’s observation or a claim about the world — an order is what a venue reported, a transaction is a line out of a venue’s own export, an asset attestation says PAXG is backed by allocated gold and a prospectus could contradict it.

“I felt rushed and broke my own rule” has no external referent at all. That isn’t a defect — self-assessment is the only way this information exists, and no venue can produce it — but it means the record’s value depends entirely on staying visibly separate from the ones that can be checked.

So self-reported is a sixth provenance in the timeline’s closed vocabulary, not a reuse of human-attested. It carries WARN for the same reason simulated does: both mark a row that reads like evidence and is not.

The CLI is the only way in, and add takes no options

Not merely “it prompts by default” — a --emotion 3 would make the entry scriptable and leave the TTY gate guarding a ceremony that no longer needed a human to supply anything. A test asserts keel/web/api.py never reaches the writer.

_is_interactive, not _require_interactive_confirmation. The heavier gate demands a typed yes and exists for dangerous actions — its own docstring warns against ceremony without a matching threat model. Writing a sentence about your own trading isn’t dangerous; it’s unverifiable, which a confirmation prompt does nothing about. What is load-bearing is the terminal: off a TTY there is no human.

And there is no edit — no update method, pinned by name, because a journal you can go back and change records what you wish you had thought.

Blank is “did not say”

Every field but the timestamp may be skipped, including all of them. rules_followed=False is the operator confessing they broke their own rules, and bool(None) would print that confession over a question they skipped — three values, three words, on every surface. An emotion score off the 1–5 scale and an impact that isn’t a number are refused, not coerced: a column holding “9”, “very bad” and “3” has three vocabularies and no series.

Nothing an operator says about themselves comes back green

Every other green on this page is a fact. “I followed my rules” is the operator’s opinion of the operator, and reflecting it back as a pass would be flattering them with their own words. NEUTRAL is the strongest honest reading, and a swept test asserts no state in this section is ever good — including a self-reported profit, where money()’s default would otherwise have graded the sign of a number the operator chose.

…truncated — full description in #736.

feat(web): the Plans page, inverted — a transparency artifact with nothing to click (#706) (#740)

Closes #706.

A retail broker’s Plans & Features page is a tier matrix with “Current plan” and “Upgrade” badges. It is radically clear about pricing and honest as marketing — every row exists to move a reader one row down. Jesse paywalls even paper trading.

keel’s constitution runs the other way: two of its eight numbered lines are “Paper is free and unlimited, forever” and “The free engine is never a demo.” So this page takes the clarity and refuses the gating.

Traceability is the acceptance criterion, so it is mechanical

“Every claim on the page traces to the evolution plan or the constitution — no aspirational copy.” A page of marketing sentences that merely sounded like the project’s documents would pass a human reviewer and rot within a month.

So every claim carries the repository-relative path it was quoted from, and the tests open that file and look for the sentence. Verbatim, never paraphrased — a paraphrase is a new claim wearing the authority of an old one.

The check runs in both directions, and the second is why it earns its place. A claim nobody can trace fails the build; so does a claim the project has since edited out of its own documents — a promise page still making a promise nobody kept. Demonstrated by mutation: adding a ninth line to §4 of the evolution plan fails the build until the page carries it, because the count comes out of the document rather than a literal.

The whole constitution, not a selection. A page that quoted seven of eight would be choosing which of the project’s own commitments a reader sees, on the page whose entire subject is what the project promises.

Nothing is for sale, and the page says so from the table

ADR 0004’s answer is “not now” and none of its four triggers has fired, so no tier is shipped. That’s a field, not a sentence, because “this does not exist yet” is the most important thing the table says and a sentence can be edited past without anything failing. The headline is derived from the field, so the two can’t disagree — and the direction they’d disagree in is a shipped product under a headline saying there’s nothing to buy.

available_now is separate and true of exactly one row: the free tier is what the reader is already running. Four rows presented as four choices would be a paywall matrix with the prices greyed out.

The refusals are structural, not editorial

…truncated — full description in #740.

feat(cli+web): the cancel asymmetry — entries ask once, protection is typed, and the console never cancels (#707) (#741)

Closes #707.

Cancelling an open entry is refusing risk — frictionless by constitution. Cancelling an open exit or protective bracket is removing protection — never one click. No venue draws this distinction; it falls out of keel’s own rails.

The scope change, and why

The issue asked for POST /api/orders/cancel. Building it would have required keel serve to hold a trading credential — and that is the boundary this project does not cross. The console holds no venue credential and no broker handle, which is what keeps the worst case of a bug in the HTTP layer at “reads a local SQLite file” rather than “exfiltrates live trading keys.” Unlocking the keychain and signing a request to a venue happens inside a terminal invocation the operator started, never from an ambient loopback daemon.

So there is no cancel route — not a guarded one, none. A guard is a thing that can be got wrong; an absent endpoint cannot be. The console classifies (a read) and hands over the exact command.

The terminal

keel orders is now a group — invoke_without_command=True keeps keel orders --scope 7d answering exactly as it did — plus keel orders list and keel orders cancel <id>.

Two things the code said that the issue didn’t:

A protective leg is classified by what depends on it, not by its side. positions.bracket_order_id is the link, and that check runs before the side test. A row wearing the BUY side while a live tranche points at it for protection would pass a side-only rule and be cancelled on a y.

The partial fill inverts the bracket rule. executor.execute places the bracket as soon as the entry is placed, not once it fills — so a resting entry can already have a protective leg. Cancel a zero-filled entry and that leg commits inventory never acquired: an orphan, cleared with the entry by the #519 protocol. Cancel a partially filled entry and the operator holds real inventory that the bracket protects; clearing it would strip a stop from a live tranche — the exit-side hazard reappearing inside an entry-side action. The remainder is cancelled; the protection stays.

And when the orphan cannot be cleared, the command fails loudly rather than reporting the cancel it managed: an entry gone with a sell still working over inventory that does not exist is a state the operator has to be told about, not discover from a fill.

The typed phrase names the order, so a phrase copied from one prompt cannot answer another — otherwise the friction is a ritual rather than a check on which protection is being removed.

The console

Every row carries the classification and the command. Clicking Cancel opens a <dialog> naming the order, saying what cancelling means, and offering the command to copy. An order that can’t be cancelled gets the reason instead of a button — a command that would be refused is worse than none.

…truncated — full description in #741.

feat(web): close #701 and #702 — the mark reconciliation pinned, the attestation chip, and a staleness verdict (#744)

Closes #701. Closes #702.

The three gaps the acceptance audit found. Each was a criterion nothing in the suite could fail on.

#701 — the reconciliation was documented and unpinned

commands/positions.py has claimed since it shipped that “THE MARK IS THE RAILS’ MARK, AND THAT IS THE POINT”, and nothing asserted it. Both real paths now run against one seeded book and the numbers are compared.

Writing it found why it needed writing: the two sides read different tables. agent._held_position derives the holding from filled live orders — the audit log, the same source guards.py uses — while the page reads the positions table’s tranches. A real cycle writes both. My first cut seeded only the tranche, so the rails saw an empty order log and reported unrealized as zero — a true statement about nothing.

A coarser series is seeded alongside the finest and holds a different close, so the test is about the granularity choice rather than about there being one candle in the database. The absent case is pinned too, because that’s where two implementations of one idea usually part company.

#701 — the attestation chip, on #718’s window

The page had a freshness chip and no attestation chip; the issue asked for both. #718 recorded attest_due_ts and keel doctor reads it — the page didn’t.

Four states, not a boolean, because the middle two are different facts: unattested (which screen_asset already rejects, so a quiet blank would say the opposite of the gate governing the holding), expired, due, and attested — which covers both “in date” and “no window recorded”, a legitimate state #718 made deliberately.

The threshold is imported from doctor, not restated: a page warning at one horizon beside a keel doctor warning at another would have an operator believing whichever they read last. Every state is WARN or UNKNOWN, never BAD — doctor’s own choice for this column, because an expired window vetoes nothing and a red chip would say otherwise.

Beside the entry gate, never merged with it: one is a claim about the world, the other about data, they disagree in both directions, and a flagged row must say which.

#702 — a staleness verdict that doesn’t cry wolf on this deployment

Every stamp crossed as moment(...), unconditionally NEUTRAL. A reading from last week rendered exactly like one from a minute ago — the page told you when and left you to do the subtraction, which Rule 2 says is Python’s job.

…truncated — full description in #744.

fix(engine): close #731 and #732 — pin two ON CONFLICT windows, and deliver the finding nobody could receive (#745)

Closes #731. Closes #732.

Both were found while implementing #718 and left alone to keep that PR to its scope. Both sit under rail 22.

#731 — two upsert clauses, correct and untested

broker_subscriptions and venue_cash_postures both carry attest_due_ts in an INSERT … ON CONFLICT DO UPDATE SET, both set it, and deleting either line left the whole suite green — the same hole #718 closed for the asset and instrument tables and left open here.

venue_cash_postures.attest_due_ts is rail 22’s input. A re-attestation carrying the old window forward would keep an expired posture reading as current, and the rail would stop vetoing when it should — the failure direction that costs money rather than opportunity.

Mutating every column of both clauses found two more the issue didn’t name:

  • attested_posture is the column rail 22 actually reads. An operator who moves off margin and re-attests, whose re-attestation silently kept MARGIN_ENABLED, would go on being vetoed by a record saying the opposite of what they stated.
  • The refutation columns had the hole the method’s own docstring already states for credential_fingerprint — “a writer that could not clear it would let a stale fingerprint outlive the record it described” — so a posture the venue refuted and the operator then fixed would read as refuted forever.

Every column in both clauses is now pinned.

#732 — a registered finding nothing could deliver

attest.cash_posture was in _ATTESTATION_FINDINGS, and notify_after_cycle never called cash_posture_findings. The registration was real; the delivery path was not.

It fires when the account is attested margin-enabled, when the posture attestation has expired, or when it was attested with no due date at all — three states where rail 22 has stopped letting the agent enter positions. The symptom otherwise is silence: an agent that looks healthy and never trades again.

The break was the same bug one layer down. events_from_state emitted one attestation event per cycle, commented “the finding list carries one rail-17 verdict” — true when the registry held rail 17 alone. With rail 22 in it, that break makes a cash-posture problem invisible whenever a withdrawals problem also exists. One event per finding name now.

The pin closes the class. A test comparing the registry against a list of gatherer names would be a third list to drift. Instead it drives the real notify_after_cycle against a book where every registered finding is unhealthy at once and asserts each one arrives — so a registration with no gatherer fails there rather than in production quiet.

One behaviour change worth naming

…truncated — full description in #745.

feat(research): persist what the gauntlet computed instead of printing it (#726) (#746)

Closes #726. Unblocks #708’s Evidence Matrix.

keel computed evidence and kept the prose. trials pbo printed ten figures and wrote none. trials deflate printed a DSR whose inputs the ledger couldn’t supply, so the number could never be recomputed or checked. trials monte-carlo stored a distribution’s ends and not its shape.

The constraint that shaped the design

Found before writing anything: _decode_summary maps a summary value to None, an int, or Decimal(value). A list raises ValueError; a dict raises TypeError. And it raises on read, inside read_trials — which every later verify_chain, trials list, trials pbo and web page goes through. One nested value would make an append-only, git-tracked file unreadable forever, with no way to take it back.

Not hypothetical: _decode_summary’s own comment records a null value doing exactly this once already.

So the guard moved to the write, where it’s a refusal rather than a catastrophe — and every artifact is stored as flat scalar keys (final_p05, not a nested ladder). The shape a reader can survive is the shape a writer may use.

What is now recorded

CSCV — every field of PBOResult: pbo, both degradation coefficients, prob_loss, both dominance flags, the column/block/combination counts, rows used and dropped, columns refused. Ten figures computed, one reached the ledger.

DSR — the inputs as well as the outputs, and the inputs are the point. --sharpe is a required operator input because the ledger stores no per-trial Sharpe, so DSR wasn’t merely expensive to recompute — it was impossible without synthesising a number the operator had supplied. A run with no --trial-sharpe-variance still refuses to compute a DSR and now also records nothing: a stored figure nobody ran is worse than an honest gap.

Monte Carlo — a seven-point quantile ladder for finals and drawdowns. Quantiles rather than the raw array (thousands of Decimals per row in a git-tracked file), and nearest-rank rather than interpolated: every value here is an equity the model actually produced, and an interpolated quantile is a number no path reached.

Two new diagnostic kinds, and why neither feeds back

cscv and deflated_sharpe are measurements about a set of trials rather than trials themselves, so both are always series_missing — and matrix.build_matrix refuses series_missing rows, so a recorded PBO can never become a column in the next PBO over the same file.

The test for that passed for the wrong reason first: it built the matrix over session s1 while the recorded row landed in session all, so the session filter excluded it and series_missing did no work at all. It now records into the same session and fails against a mutant that gives the row a real series.

Refusals

…truncated — full description in #746.

feat(web): the Evidence Matrix, read rather than computed (#708 view 2) (#747)

Closes #708. Closes the honest cockpit milestone.

The last of #708’s four views, and the one that waited for an engine record instead of being built on a guess.

Why it is a read

Building the matrix on request costs 11.9 / 12.9 / 14.3 seconds per session on the real ledger — ~39 s of CPU for three sessions, on a page the console re-polls every 15 s. And over the ledger as a whole it doesn’t run at all: build_matrix requires synchronous columns, so a PBO is only ever defined within a session whose trials share a bar count, and a page can’t pick that scope without inventing an operator’s decision.

So #726 made trials pbo record every field of its PBOResult, and this reads them. The console displays results an operator ran and never runs one on their behalf — pinned by a test that makes build_matrix raise if it’s called.

An unrun matrix is not an empty one

Three states, which is why the report carries candidate_sessions at all: no ledger · a ledger with columns and no recorded run · a ledger with runs. The middle one is the state this ships in, and the only one that can be acted on.

The guidance names a session that actually has columns. keel trials pbo --session all is the obvious thing to print and would filter to a session literally called “all”, find nothing, and refuse — teaching an operator that the page doesn’t know what it’s talking about. So the report counts the trials build_matrix would accept and names one of those. On the tracked ledger that’s pbo-grid-entry-lookback-2026-07-20, one of three genuine candidates.

It deliberately doesn’t check synchronicity, which would mean reading every series — most of the cost this module exists to avoid. Suggesting a session that might turn out ragged, and getting a clear refusal from the command, is a much smaller harm than a page that costs twelve seconds to render.

PBO carries no judgement

It’s the one figure a reader will want graded, and grading it is what the rail refuses: a high PBO beside a flat, positive OOS scatter is the good outcome — a broad plateau of near-identical configurations produces high PBO by construction. trials pbo’s own closing sentence says to read it alongside the degradation slope, never alone, and both cross plainly so a reader can.

The dominance flags do carry a state, because they’re already verdicts — and they’re three-valued: False says the distribution did not dominate, which is not what an absent field says.

⛔ No sortable column on the route and no sort key in the view. A matrix ordered by PBO is a leaderboard of overfitting scores, and cscv.py forbids PBO as a ranking key in its own source.

Two tests that weren’t testing anything

…truncated — full description in #747.

docs(web): script the half of #648’s secure-context check a shell can do (#748)

Partial for #648 — does not close it, and the reason is the point.

Four of #648’s five items already shipped in #678 and #680. I mutation-tested all six defences rather than trusting they were covered — skipping the Host check, admitting a non-loopback name on a loopback bind, dropping the port check, accepting a wildcard external host, making the remote session immortal, removing the wildcard-bind guard — all six killed.

The fifth item, secure-context re-verification, needs a real deployed origin. This turns that from an open research question into a two-minute run.

Two parts, and the split is the honest shape of the requirement

Part 1 — what HTTP can answer. The shell served over the external origin; the token exchanged for a SameSite=Strict cookie there; manifest.webmanifest and sw.js actually served; nosniff present; and the one that matters most — a spoofed Host: still refused. --external-host teaches the server one name, and a deployment where Host: evil.example is answered is one where the allowlist has been widened until it stopped being one. Non-zero exit on any failure.

It refuses a plain http:// origin outright rather than warning: every check is about what HTTPS provides, and against http:// it would report a pass for a page that is not a secure context.

Part 2 — what only a browser can answer. window.isSecureContext, whether the worker registered and with what scope, whether the cache is genuinely per-origin, whether the manifest installs, and how the installed console behaves when the tunnel dies. Printed as a checklist and not covered by the exit code — a script claiming isSecureContext from curl would be reporting something it never looked at.

A test asserts part 1 makes no such claim, scoped to the executable region so the header comment may still name the property in order to say it cannot check it.

Why this doesn’t close the issue

docs/remote-access.md keeps its ⛔ section and now points at the script. #648 closes when part 2 has been done on a device and recorded on the issue. Part 1 passing is not sufficient, and no amount of scripting makes it so.

The script joins the shellcheck suite, because a shell bug in it is a bug in the one check standing between an operator and an exposed financial surface.

Gates

6,297 passed / 3 skipped; ruff clean (the one E501 is pre-existing); shellcheck clean.

docs(readme): the fee-reality benchmark as a terminal capture, generated from a real run (#646) (#749)

Closes #646 — the last deliverable. The README hook, the priced-twice table, the reproducible figures and scripts/render_fee_reality.py all shipped earlier; what was missing was the terminal capture.

One constraint decided the shape

“Generation tooling committed … so the GIF regenerates when the measurement does.”

A hand-recorded screencast can’t satisfy that. It’s a binary blob whose numbers freeze the moment somebody hits record, and the first time the ledger moves it becomes a picture of a measurement that is no longer true — a marketing asset wearing a measurement’s clothes, which is the exact thing this README argues against.

What is real, and what is not — said in the caption

render_fee_reality_cast.py runs render_fee_reality.py as a subprocess and captures its actual stdout. That command really reads docs/experiments/trials-ledger.jsonl, really parses the recorded fee curve, really prints those figures. No number is retyped and there’s no fixture — a test asserts the committed asset carries the same cells the renderer emits today, and swapping the capture for a fixture kills it.

The pacing is composed. Nobody types at a uniform 55 ms per character. The README caption says so in a sentence — “The pacing is composed; the output is not” — because a capture presented as an unedited recording would be a small lie in service of a page about not telling them.

Two formats, one recording
filewhy
docs/assets/fee-reality.castasciinema v2 — plain JSON lines a reviewer can read; any asciinema tool plays it
docs/assets/fee-reality.svgwhat the README embeds: renders inline on GitHub, no player, no CDN, no JavaScript — and it’s text, so a regenerated capture arrives in review as a legible diff

The SVG is hand-built rather than shelled out to agg or svg-term-cli, for the same reason the asset is generated at all: a capture produced by a toolchain this repo doesn’t have can’t be regenerated by someone who clones it.

Deterministic, so its diff means something

No timestamp in the cast header — that’s where asciinema puts the wall clock, and with one every regeneration would be a diff even when the measurement hadn’t moved. Verified byte-identical across runs.

#646’s other constraints
  • ✅ Every number reproducible from a recorded run — a test compares the capture against the renderer’s live output
  • ✅ No competitor naming — swept for five product names across both assets
  • ✅ Dated caption with the per-venue fee basis in frame — Coinbase taker 1.2% · rendered from the trials ledger
  • ✅ Generation tooling committed, with a check mode wired into the suite
Gates

…truncated — full description in #749.

chore(release): 0.14.0 — the honest cockpit, and a book that can prove it was not edited (#750)

Version bump only. Thirty-seven PRs since 0.13.3.

⚠️ This release carries four schema migrations

v0.13.3 shipped SCHEMA_VERSION = 16. This release is 20, so an upgraded deployment applies four steps, not one:

adds
v17candle_series_feed (#696)
v18venue_cash_postures (#666)
v19equity_points (#698)
v20orders.quote_provenance, orders.client_order_id, attest_due_ts on both attestation tables, cycle_balances, audit_events (#721)

keel serve migrates for you, once, at bind time (server.ensure_schema) — a missing database is left alone as a first run. What it deliberately does not do is migrate per request: the pages auto-reload every 15 seconds, and a view calling itself read-only must not take a schema write lock on a database the agent may be mid-cycle on, four times a minute.

keel mcp does not migrate at all (keel/mcp/tools.py), and against a database still at v16–v17 its doctor tool raises sqlite3.OperationalError: no such table: venue_cash_postures rather than reporting the gap. Only the audit chain carries a table_present guard; the cash-posture and equity readers do not. Tracked in #751 — run keel migrate before pointing an MCP client at an upgraded deployment.

The honest cockpit (#700–#708)

Positions, Balances, Activity, Orders, the Research Hub’s four views, session identity on every page, and an inverted Plans page.

The pattern underneath all of it: a view was built against a record the engine didn’t keep, and the fix was always in the engine. #703’s audit export said NOT RECORDED in every hash cell until #721 chained the book. #708’s Evidence Matrix couldn’t exist until #726 stopped throwing away what trials pbo computed.

What the engine now writes down
  • An append-only, hash-chained record of every write to orders, transactions and the attestation tables (#721) — the activity export carries real hashes and a per-row verdict on whether the chain vouches for them
  • Quote provenance and client order id behind each placement (#715)
  • When an attestation’s window closes (#718)
  • The per-currency balance pair a cycle observed (#719)
  • The full CSCV, DSR and Monte Carlo artifacts a gauntlet run produces (#726)
Two new operator surfaces

keel journal — the discretionary journal, wired at last after sitting in the schema unused since the beginning. CLI-only by constitution: no web form, and add takes no value options, so an entry can’t be scripted past the terminal gate.

keel orders cancel — the cancel asymmetry. Cancelling an entry refuses risk and asks once; cancelling an exit or protective bracket removes protection and takes a typed phrase naming the order. The console classifies and hands over the command; it never cancels, because keel serve holds no venue credential and this release doesn’t change that.

One behaviour change to know about

…truncated — full description in #750.

fix(mcp): a reader that does not migrate must report the gap, not raise on it (#751) (#752)

Closes #751.

keel mcp’s doctor tool raised sqlite3.OperationalError: no such table: venue_cash_postures against a database nobody had migrated — out of the handler, taking every other finding with it, on the first tool a client typically calls.

The rule was already written next door

audit_chain_findings states it in its own docstring: a reader that does not migrate meets an un-upgraded schema as an ordinary deployment state, not an error. It backs that with a table_present check and an OK finding naming keel migrate. Three migrations later, venue_cash_postures (v18) and equity_points (v19) had no such guard.

The check moves from audit.py to db.table_present, where the schema is defined and every reader can reach it, plus Repository.table_present for the reason audit_chain is exposed there: what this record contains is a question about the record.

Checked, not caught. OperationalError covers “no such table” and “database disk image is malformed” under one clause, and that distinction is the difference between keel migrate and stop trading.

An absent table is not an unattested posture

A bare None would have been worse than the crash. cash_posture_findings(None) is FAIL “cash posture never attested — rail 22 vetoes every live ENTRY”, which is false about a database that has no posture table: nothing has lapsed, the schema simply predates the rail. It would have sent an operator to fix a rail that is not the problem.

cash_posture_schema_finding says the true thing instead, and OK rather than WARN — the engine that enforces rail 22 migrates on the way in, so a live cycle can never be running against this schema.

Why the existing test could not see it

test_a_database_without_the_table_is_reported_not_crashed_on asserts over a fabricated _chain(table_present=False). A hand-built state object can only exercise the reader that already has the flag, so the readers that lacked one stayed invisible to it.

The new tests open a real database wound back to what 0.13.3 shipped, and the MCP one sweeps every tool rather than doctor alone — a tool added later inherits it, a table added later joins one tuple. Verified by mutation: forcing table_present to True fails all three.

And the docstring that caused it

audit_chain_findings said “both the web server and keel mcp open a repo WITHOUT migrating”. Only the per-request open skips it; web/server.serve calls ensure_schema once at bind, so a served database is never behind. That imprecision is what left the sibling readers looking already covered — and it had propagated into v0.14.0’s release notes before this was traced. Both are corrected.

Checks

6,308 passed / 3 skipped (6,305 + the three added here), ruff and mypy clean.

Not a blocker for v0.14.0

…truncated — full description in #752.

v0.13.3

Voir cette version sur GitHub ↗

Built from 418c1f57ec35. Version binds to this hash: keel --version reports keel 0.13.3+418c1f57ec35 [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.13.3-py3-none-any.whl
keel versions

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

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

Configure

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

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

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

Desktop app (macOS / Windows)

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

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

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

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

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

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

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

A SHA256SUMS-<platform>-<arch>.txt file per build is attached as well (SHA256SUMS-macOS-arm64.txt, SHA256SUMS-macOS-x86_64.txt, SHA256SUMS-Windows-x86_64.txt) — pick the one matching what you downloaded. If either check fails, do not run it.

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

Other changes

chore(actions): name every run, and stop re-scanning the merge commit (#674)

1. run-name on all seven workflows

The Actions list titles every run with the commit message, so one push to main produced four entries all reading Merge pull request #673 from CodeGateSoftware/chore-v0.13.2-bump — four different checks that looked like one job running four times.

12:22:08Z  security          ev=push  sha=6ca893a  title=Merge pull request #673 from …
12:22:08Z  Migrate database  ev=push  sha=6ca893a  title=Merge pull request #673 from …
12:22:08Z  CI                ev=push  sha=6ca893a  title=Merge pull request #673 from …
12:22:08Z  Code Quality      ev=push  sha=6ca893a  title=Merge pull request #673 from …

Applied to seven, not the four that collide today — install-smoke and installer-smoke already run on PRs, and a release run under a commit-message title is the same confusion, just rarer.

Deliberately not github.event.head_commit.message. That was the obvious interpolation and it’s the wrong one here: this repo writes long multi-paragraph commit messages, GitHub already shows the commit beside every run, and re-titling with it restores exactly the clutter being removed. What the default title drops is the workflow’s identity, so that’s what run-name carries — name, event, ref:

CI · push · main
Security audit · pull_request · chore-actions-runname
2. security.yml no longer runs on push

Both its jobs ran a second time on the merge commit to re-answer what the PR run answered four minutes earlier. dependencies audits uv.lock, which the merge doesn’t change, and neither job reads anything a merge could combine differently.

Measured over the last 40 runs: 11.4 minutes, ~2.5 per merge (30s dependencies, ~2min codeql).

ci.yml keeps its push trigger. When two PRs merge in quick succession the post-merge run is the only thing that ever tests the tree that actually landed — and that argument doesn’t transfer to a dependency audit.

⚠️ The tradeoff, which your rationale didn’t cover

Code scanning reports alerts against the default branch, and that baseline is refreshed by analyses of the default branch — which now means the weekly cron alone. A finding introduced by a merge, or an alert a merge fixes, can take up to seven days to appear or clear in the Security tab.

PR analysis is unaffected and still runs on every change, so nothing reaches main unscanned; what’s slower is the summary of what’s on main, not the scanning. It’s recorded in the file with the note that if the lag ever matters, restore push rather than shortening the cron — a daily scan pays most of the cost for a fraction of the freshness.

Happy to revert this half alone if you’d rather keep same-day Security-tab accuracy; the run-name half stands on its own.

Verification

New pin discovers every *.yml from disk rather than listing them, so a workflow added tomorrow is covered on the day it lands. 4 mutants, 4 killed — including one asserting the commit-message interpolation is rejected. Full suite 5110 passed / 3 skipped; ruff clean.

fix(update): retry a transport stall, and stop paying 466 MB before the step that fails (#675, #676) (#677)

Closes #675 and #676. Both came out of the failed keel update to 0.13.2 this morning.

downloading keel_core-0.13.2-py3-none-any.whl
update did not complete: could not download …: The read operation timed out

The asset was healthy the whole time — HTTP 200, 51,701 bytes, 0.82s a minute later, download counter still 0. A transient stall cost the entire update.

#675 — retries

_download_file made one attempt, and _HTTP_TIMEOUT_SEC = 15 is a per-socket-read timeout, not a deadline for the transfer — so a CDN going quiet for fifteen seconds fails a 51 KB download. Five wheels, five independent chances to trip. _http_get had the same shape and aborts the update before it even starts.

The trap this avoids. urllib.error.HTTPError is a subclass of OSError, so the obvious except OSError: retry retries a 404 as eagerly as a dropped connection — three times wrong instead of once — and retries a 403, which on the unauthenticated releases endpoint is the rate limit, spending the budget that just ran out. _is_retryable judges an HTTPError by its status and treats everything else as transport.

429 is deliberately not retryable, even though it’s transient. It means the 60-per-hour budget is spent, and asking again is the one response that can’t help; _http_get already turns it into a message naming the manual procedure, which needs no API call at all.

Timeout 15 → 30: with retries in hand, 30-and-retry recovers from more than 60-without while giving up on a genuinely dead host sooner. Backoff is a tuple rather than a formula so the added latency of a doomed update is legible at a glance — 4 seconds, once.

#676 — order

Backups ran before any download, on the reasoning that “if anything after this point half-happens, the databases’ pre-update state exists on disk.”

That guarantee is real and unchanged here: a download cannot make anything half-happen to a database, because _download_file writes only into Release/, and the two steps that do touch one — install and migrate — still run after every backup exists. The existing pin (_FakeOps.install asserting backups are already on disk and open cleanly as SQLite snapshots) passes untouched.

What the old order did was spend the expensive work before the failure-prone one: ~466 MB of sqlite3.backup() across three databases, thrown away by a stall on the first wheel. Backups are timestamped and deliberately never deleted, so the retry left a second full set behind rather than reusing the first — ~/keel holds 7.0 GB of them going back to 0.9.1.

Plan and gate wording updated to match: back up first → then back up.

Verification

12 mutants, 12 killed, and the last three were the lesson:

  • _with_retries being correct proves nothing about _download_file using it. Every retry test called the helper directly, so removing the call from the production downloader left them all green. Same for _http_get. Both now have tests that drive the real function through a monkeypatched urlopen.
  • backups=() on a failed download passed against a glob("*.bak-before-*") implementation until a test seeded an earlier run’s backup — which is the normal state of a real launch folder, not an edge case. Reporting someone else’s backup as this run’s would send an operator to restore from the wrong file.

One more worth recording: a mutation whose anchor appeared three times never applied and read as a survivor until the count was checked.

Full suite 5124 passed / 3 skipped; ruff and mypy clean.

Note

This does not fix the update you ran — re-run keel update whenever; the release is healthy. This makes the next stall a non-event.

feat(web): expect a tunnel’s own hostname, one name at a time (#648, partial) (#678)

⚠️ This does not complete #648, and nothing remote should ship on it. Two of the five requirements are met; three are not. The issue stays open, and docs/remote-access.md says so at the top rather than leaving it to be discovered by someone who read a changelog line and exposed a console.

The problem

A Cloudflare Tunnel forwards to loopback and presents the app’s public domain in Host:. The DNS-rebinding defence refuses it — correctly, and for exactly the same reason it refuses evil.example: from inside the process the two are indistinguishable, both being names that resolve to a machine this server did not bind.

Nothing in the request can tell them apart. Only the operator can, so only the operator can name one:

keel serve --external-host keel.example.com
Extends, never replaces

Checked before the bind-derived rules and changing neither — every loopback spelling still answers, every unlisted name is still refused, and the port check still applies to a proxied request. Matching is case-insensitive because DNS is; an operator whose correct-looking config silently refused everything would have no way to see why.

No wildcards, refused at startup. *, *.example.com, .example.com, 0.0.0.0, ::, any, all and the empty string all raise. A wildcard is not a wider expectation, it is the absence of one — * answers evil.example exactly as readily as the operator’s own domain, which is the attack. A leading-dot suffix admits every subdomain an attacker can provision, and a tunnel presents one name.

The bug its own test found

Worth keeping, because it’s the whole argument for testing a control rather than reading it.

ServeConfig.host_policy is a lazy property, so HostPolicy.__post_init__’s wildcard guard fired on the first request, not at startup. --external-host '*.example.com' started cleanly, said nothing, and would have raised somewhere inside a handler. My docstring claimed the opposite. The command now builds a policy eagerly and converts the refusal into click.BadParameter.

The threat model, written down

docs/remote-access.md states what each transport actually guarantees rather than “it’s encrypted”:

who can read itwhat the operator is trusting
Loopback onlyanything running as the operatorthe machine
WireGuard meshonly the operator’s enrolled devicesthe coordination server for keys, not content
Cloudflare TunnelCloudflare, in the clear at its edgeCloudflare with the plaintext of every page and session token

That third row needs saying out loud. Cloudflare terminates TLS. In that mode the operator isn’t trusting it to route traffic — they’re handing it the readable contents of a console showing positions, balances and attestations. That may be an acceptable trade; it is not a neutral one.

Still open (#648 stays open)
  • Session tokens over a remote origin — entropy stated against an internet-reachable attacker, issuance rate-limiting, brute-force posture. On loopback there was no attacker to rate-limit.
  • Secure-context re-verification — the service worker and manifest work because http://127.0.0.1 is a secure context by specification. Over an external origin that comes from HTTPS instead and must be re-verified, not assumed.
  • --host 0.0.0.0 — today it produces a server that refuses every request, because HostPolicy then expects Host: 0.0.0.0, which no browser sends. Fails closed, which is the safe direction, but fails confusingly and needs its own decision.
Verification

10 mutants, 10 killed. The last was CLI-side normalisation — invisible until a test typed the name the way a shell or config file actually hands it over (" KEEL.Example.COM ").

Full suite 5145 passed / 3 skipped; ruff and mypy clean.

docs(readme): lead with the fee-reality benchmark, rendered from the ledger (#646, partial) (#679)

Partial. #646 also asks for a ~10-second terminal capture of a real run with committed generation tooling. That is unbuilt and the issue stays open for it. This is the numbers half — and the half a GIF would have to agree with.

The README already stated the honest result in prose. #646 asks for it as numbers: the same rule priced twice, before the feature list.

assettradesPF at 0% feePF at 1.2% takerbreak-even fee, measured
BTC1231.0900.3330.068%
ETH1211.4580.5560.433%
SOL921.5330.8010.751%
ZEC502.7131.3031.741%

Four of four profitable with the fee removed. Zero of four survive the fee actually charged. Break-even spans 0.068% to 1.741% — a factor of ~26 across four assets running the same rule on the same clock over the same window.

Rendered, never typed

That is the issue’s binding constraint, not a nicety. Every figure is read out of docs/experiments/trials-ledger.jsonl — the hash-chained record of what was actually run — by scripts/render_fee_reality.py and reformatted. A hand-typed benchmark would be a marketing claim wearing a measurement’s clothes, in the one block whose entire argument is the difference between those two.

tests/test_fee_reality_block.py fails if the README and the renderer disagree, and separately asserts that every number in the block appears in the ledger row — stronger than “the renderer produced it”, because a renderer with a literal baked in would satisfy the drift pin with both sides carrying the same invention.

The caveats travel with the numbers

Pinned by a test, because dropping them would make the block a stronger claim than the measurement supports:

  • Every configuration is the argmax of its asset’s 144-cell slice, selected on the same data it is re-priced on. Maxima of 144 draws, not edge estimates — the ledger’s selection_bias field says so at full strength.
  • The bias runs against the finding, which is why the comparison survives it: it inflates the arm that wins with the fee removed, and that arm still dies when the fee is charged.
  • Slippage is held at 0.0005 in every cell, so the zero column is zero fee, not zero cost.
Parsing prose is deliberate

The ledger stores the curve as a recorded sentence, because that row was written by the experiment that produced it, and rewriting history to suit a renderer would be the tail wagging the dog. So the parser is strict and every recognition failure is fatal — a parse that silently produced three of four assets would be the quiet half-truth this block exists to refuse.

Two bugs the tests found in the renderer’s own output:

  • [\d.]+ is greedy enough to swallow the sentence’s full stop into the last asset’s profit factor. 1.303. is still readable, still wrong, and would have shipped inside a table.
  • Fee columns rendered as raw fractions (PF at 0.012), making the reader perform the conversion that produced #247’s costing error in the first place.
Verification

9 mutants, 9 killed — and the last took three attempts to write. The column-count guard sat behind the missing-break-even guard, so every input meant to reach it tripped the earlier one instead, and the mutation survived while the test looked like it pinned something.

Full suite 5135 passed / 3 skipped; ruff and mypy clean.

feat(web): a remote session expires, and a wildcard bind must say what it expects (#648) (#680)

Three of #648’s five requirements. The first finding is that one of them should not be built.

No rate limiter, and the arithmetic is why

The issue asks for “issuance rate-limiting and brute-force posture”. The token carries 256 bits (secrets.token_urlsafe(32)) — a space of ~1.2 × 10⁷⁷. At a billion guesses per second, which no http.server on a laptop will serve, covering a meaningful fraction takes on the order of 10⁶⁰ years.

Guessing is not a threat this server has. A limiter installed to stop it would be theatre: state, a failure mode, and a false sense that something was closed. TOKEN_ENTROPY_BITS records the number so it travels with the claim, and the comment says that if a limiter is ever added it must be justified by bounding log volume or making probing visible — never by brute force.

What a remote origin actually changes

Not the strength of the token — who can use one that leaked.

On loopback that population is software already running as this operator, against which no session lifetime helps at all. Through a tunnel it becomes anyone who can reach the origin, and the token has been in a URL, in terminal scrollback, and in whatever got pasted while asking for help. The 30-day cookie Max-Age is a browser hint such an attacker ignores entirely.

So a server configured with --external-host enforces a 12-hour session lifetime on its own side of the wire, checked before the token so an expired session cannot be told apart from a wrong one by which refusal comes back. A loopback-only server has none.

That asymmetry is the argument, not an exemption: SESSION_COOKIE_MAX_AGE_SECONDS’s reasoning for the 30-day cookie is sound and is left completely intact. Restarting keel serve remains the instant revocation gesture in both postures.

A wildcard bind must name what it expects

--host 0.0.0.0 produced a server that refused every request — HostPolicy would then expect Host: 0.0.0.0, which no browser sends. It failed closed: right direction, wrong explanation, and the operator’s conclusion was “keel is broken” rather than “keel does not know which name to expect”.

A wildcard is precisely the bind where the name cannot be derived, because every interface has a different one. So it is the one bind that requires stating it. Not a blocklist — permitted the moment --external-host says what to expect, pinned by a test, and a specific --host 10.0.0.5 is untouched.

Still open — #648 stays open

Secure-context re-verification. The service worker and manifest work today because http://127.0.0.1 is a secure context by specification; over an external origin that property comes from HTTPS instead and has to be re-verified there. That needs a real deployed origin and cannot be closed from a checkout — so an installed console reached through a tunnel is untested, not merely unsupported, and docs/remote-access.md says exactly that.

Verification

6 mutants, 6 killed. One is worth keeping: shrinking the lifetime to one second passed everything, because the tests only pinned that it was shorter than the cookie. A lifetime that expires mid-use is not a session bound — it is the “refuses every request” outage the wildcard refusal exists to prevent. It is now pinned at both ends.

Full suite 5166 passed / 3 skipped; ruff and mypy clean.

feat(doctor): count the update backups nobody was ever told about (#681) (#682)

Closes #681.

keel update copies every keel*.db before it installs and never deletes one. That is correct — update.py names them as the data-recovery path, and an updater that pruned its own rollback would be an updater you cannot roll back from. What was missing is that nothing ever told the operator they were there.

The live deployment, measured 2026-09-01
88 files — 7.0 GB in ~/keel, plus 408 MB in ~/keel/backups
database backups for 24 releases, going back to 0.4.0

Three databases at 130–180 MB, one set per release, roughly a release a week — so it grows ~470 MB per update and nothing bounds it. df says 343 GB free, so this is not urgent. It is invisible, which is the actual problem: the first signal would be a disk filling during an update, the one moment a rollback path matters most.

Surface, never delete

doctor gains backups.footprint — WARN past three per database, OK below. A handful of recent backups is the design working, and warning about three would train the finding to be ignored by the time it matters.

Two tests carry the “never delete” half:

  • the fix text must say prune by hand and that keel will not do it — asserted to contain no keel backups prune, no --prune, no rm;
  • a scan of keel/ for any line that both mentions bak-before and calls unlink/rmtree/os.remove. A future change to the rollback guarantee has to delete that test to make it.

The count is the actionable number, not the bytes. “keel.db: 23 going back to 0.4.0” says what to do; “7 GB” says only that something is large. A hand-named backup (keel.db.bak-before-recordflow-…, which exists in the live folder) is never sorted as the oldest release — reporting “recordflow” as the oldest is both wrong and unactionable, since you can’t decide whether to keep a release you can’t name.

keel update’s plan also names what is already kept, immediately before another set is written.

Verification

9 mutants, 9 killed — and two changed the code, not the tests:

  • Replacing the launch-folder read with an empty footprint passed everything. The seeded deployment has no backups, so both paths reported ok and the finding was never shown reading anywhere real. There’s now a test that seeds four and monkeypatches update._launch_dir.
  • The try/except OSError around the glob was unreachable. Path.glob returns an empty iterator for a missing directory and for one at mode 000 alike, so deleting the handler changed nothing — which is how it was found. It’s gone, with the correction recorded in its place: what genuinely races is stat on a file that vanished between the glob and the read, and that one is guarded where it can actually happen.

Full suite 5177 passed / 3 skipped; ruff and mypy clean.

feat(rules): CUSUM event gating, with the threshold priced in round trips (#341) (#683)

Research brief B-1, prerequisite-gated on #339’s honest-cost re-measurement — which closed 2026-08-17. A fifth RULE_REGISTRY kind: a deterministic trading-core change, filed as an issue first, exactly as the registry’s own comment requires.

What it does differently

The three shipped signal rules evaluate a condition on every bar. This one asks a prior question: has price moved far enough since the last event to be worth evaluating at all? A symmetric CUSUM filter accumulates returns from a rolling anchor and fires only when the running sum crosses a threshold.

The threshold is a multiple of friction, not a percentage

This is the whole design ask of the issue. The source’s 2.0–2.5% threshold is not conservative here — it is almost exactly one round trip on this venue (2 × 1.2% taker + 2 × 0.05% slippage = 2.5%). So “a 2.5% move” names a move that pays for the trade and leaves nothing.

threshold_friction_mult puts that in the knob itself: 1.0 reproduces the paper and says out loud that it is break-even before the order is placed. Default is 2.

ROUND_TRIP_FRICTION_PCT is imported from strategy.backtest rather than restated, so a rule defined as a multiple of friction cannot drift from the number the backtest charges it. The percentage is a derived property, never persisted — a stored 2.5% would keep answering 2.5% after a fee change that made 2.5% mean something else.

The reset is the load-bearing part, and my first draft didn’t have it

Without resetting the crossing side, S+ climbs monotonically through a rally and stays above the threshold for every later bar — so the rule fires on every bar of the move. That is the every-bar evaluation it exists to replace, wearing a threshold.

It was caught by the test fixture: a rally with “comfortably more” rise than the threshold does not fire, because the crossing already happened earlier in the window and the anchor moved. The fixtures are built to cross on the final bar, and that constraint is the reset property made concrete.

Both sides are tracked though keel can act only on the upward one — a one-sided filter accumulates an unbounded downward sum that never resets and then mis-times the next upward event. The exit is the same filter on the other side, symmetric on purpose: a different exit threshold would be a second free parameter with no evidence behind it. #342’s triple-barrier exits are deliberately not smuggled in — this rule must be measurable alone before it is combined.

⚠️ The honest prior, in the module and in the registry

Zero of ninety asset-rule-parameter combinations are simultaneously measurable (n≥100), gross-positive and net-positive at any fee this venue offers. Nothing here is expected to change that.

Worse: this rule’s own mechanism cuts trade count, and the ρ=−0.77 bind between edge and sample size says the rules with enough trades to promote are the ones without edge. A gate that trades less is walking into it.

It ships to be measured, not to be believed. Nothing is promoted by this PR; no rule row is added; the live allowlist and config are untouched.

One thing that moved on its own

SEARCH_SPACES gained a fourth family without anyone editing research/tuning.py — since #528 it reads the rules’ own param_space(), so declaring a space is what enrols a rule. I updated that pin with the reason written down, because enrolment is now a side effect of a declaration made in another file, and a rule that gained a space by accident would otherwise appear in a sweep budget silently.

Verification

9 mutants, 9 killed — including the reset, the stale-event carry-forward, the friction scaling, the dropped downward side, and the granularity fallback.

Full suite 5210 passed / 3 skipped; ruff and mypy clean across 121 source files.

docs(experiments): cusum_event measured — feasible, and without a gross edge (#341) (#684)

The measurement #341 asked for: pre-declared, then run. 168 trials, 24 assets, ~5 years of hourly candles, fees 0 / 0.006 / 0.012.

1. Feasibility: yes — and it contradicts the worry filed with the issue

#341 was filed with the concern that “gating cuts n on rules already below the admission floor.” It does not.

assets with n ≥ 10021 of 24
median n553
range32 – 884

A 5% cumulative move in an hourly crypto series is a common event, not a rare one. The three below the floor are the two PAXG pairs and TON — gold and the thinnest name in the universe, which is the expected shape. That objection is answered and shouldn’t be repeated.

2. Profitability: no, and not marginally
feePF medianPF maxabove 1.0
0%0.9251.2388 / 24
0.6% (maker, unreachable)0.5530.7500 / 24
1.2% (taker, actual)0.3430.4790 / 24

The intersection of n ≥ 100 and PF > 1.0 is empty across all 168 trials.

3. The diagnostic — this is the rsi_meanrev disease, not the turtle_breakout one

The number that matters is the zero-fee column: only 8 of 24 clear 1.0, median 0.925.

turtle_breakout at zero fee was profitable on 4 of 4 with a max of 2.713 — a real gross edge that cost destroyed, which is why cheaper execution was a coherent thing to want. This rule loses at a median before a single fee is charged. Same symptom as turtle at 1.2%, different disease — and the one execution cannot fix, because zero fee bounds from above everything an execution improvement could buy, and zero fee is already a loss.

4. The knob works exactly as designed, and converges to nothing
multthresholdmedian nPF medianabove 1.0
12.5%12520.2260
25.0%5530.3430
37.5%3300.3610
410.0%2240.3790

Monotonic in the right direction — trading less does lose less per toll, so the mechanism is real. It is also converging to a ceiling well below 1.0 while n falls toward the floor. A fourfold threshold buys 0.15 of profit factor and costs 82% of the sample.

5. The source’s own setting is the worst cell

mult=1 is the paper’s 2.0–2.5%, which on this venue is exactly one round trip: lowest median PF (0.226) with the highest median n (1252) — the most trading at the least edge. That’s #341’s friction-scaling argument measured rather than asserted.

Honesty

Arm A is one pre-declared configuration, so it carries no selection bias. Arm B’s per-asset best is a maximum of four draws and is stated as such — best cell CRV-USD at mult=4, PF 0.593, still 0.4 short, so the bias changes nothing either way. Screening result only: no walk-forward, no PBO (series_missing). Slippage held at 0.0005 throughout, so the zero column is zero fee, not zero cost.

The ledger row was written twice

Worth recording. Every non-int summary value must be a string: _decode_summary turns anything that isn’t an int into a Decimal on read, so a JSON float round-trips to a different value and the row stops hashing to its own row_hash.

verify_chain caught it immediately. The corrupt trailing row was removed — restoring the chain to exactly its prior state, verified clean at 90 rows — and re-appended with the encoding every existing row already uses. The chain verifies at 91 rows.

Changed nothing else

A document, a driver script, a ledger row. No rule row added, nothing promoted, no config or allowlist touched. cusum_event stays registered and untraded.

The measured intersection goes from 0 of 90 to 0 of 114. That is the result, and publishing it is the point.

feat(rules): triple-barrier exits with a vertical time stop, and the measurement (#342) (#685)

Research brief B-2, prerequisite-gated on #339 (closed 2026-08-17). A sixth RULE_REGISTRY kind, and the A/B it exists to make possible.

The result, in two numbers

cusum_event and triple_barrier share an entry — same CUSUM filter, same threshold, same universe, same window. Only the exit differs, so the difference is attributable.

zero fee1.2% taker
control (cusum_event) median PF0.9250.343
triple_barrier median PF1.0010.338
median delta+0.033−0.004
assets improved17 of 2411 of 24 (a coin flip)

The exit genuinely works. At zero fee it lifts the median profit factor across break-even and improves 17 of 24 assets — the first number in this series of documents to move the right way.

And it is worth nothing. At the rate the account pays, the median delta is −0.004. The gross gain is smaller than the friction it has to be harvested through.

Zero of 24 clear PF 1.0 at the taker rate. The only two cells above 1.0 anywhere are TON-USD at n=17 and n=16 — a sixth of the admission floor, maxima of five draws on the thinnest asset. The intersection is empty across all 192 trials.

What’s new in the rule
  • A vertical barrier — sell at the close after N bars, whatever price has done. No shipped rule has a holding-duration exit, and it’s the leg of the source’s method that transfers: executable under market fills, where a resting order at a horizontal barrier is not.
  • Barriers as multiples of the product’s own round trip (#342’s design ask). The source’s ±2.5–5% sit at or below one round trip here, so transplanted as percentages they’re mechanically dead. A fixed percentage is also wrong per asset — since #259 the backtest prices thin books to 183.8bp per leg, so the same 5% is four round trips on BTC and barely one on the tail.
Two things worth your eye

A unit trap that produces no error. median_daily_quote_volume returns a per-bar median despite its name, and slippage_for_quote_volume is anchored on $500M daily. Handing it the hourly figure unscaled reports every asset as maximally thin, clamps the universe to the 183.8bp cap, and makes every barrier four times too wide — silently. per_product_round_trip scales by bars-per-day; a test asserts the consequence rather than the call.

A bug the mutation run found in my own guard. if not stop < entry < target passes a negative stop: a large enough stop_friction_mult drives the barrier below zero, the ordering still holds, and risk = entry − stop then exceeds the entry itself — sizing a position off a loss larger than the whole holding. It’s 0 < stop < entry < target now. Unreachable inside the declared param space, reachable from a stored row, which is why it isn’t dead code.

The vertical axis
barsmedian nPF medianabove 1.0
65200.1610
124860.2430
244310.3380
483740.3921
723380.4641

Monotone, and the source’s own 24-bar barrier is mid-range rather than optimal. The direction agrees with its “wide barriers beat next-bar labeling” finding; the magnitude doesn’t, because 1.2% per leg is twelve times the 0.1% that paper priced.

What this settles

Both halves of Grądzki et al. are now implemented on keel’s cost structure and measured on one universe: the entry half has essentially no gross edge, and the exit half is a real improvement friction consumes entirely.

The paper is not wrong about its own venue — at 0.1% per leg a +0.033 gross improvement is worth keeping. At 2.5% it is not. That’s the clearest measurement in the series of the difference between a result and a result at a price.

Verification

9 mutants, 9 killed. Full suite 5241 passed / 3 skipped; ruff and mypy clean. Ledger verifies at 92 rows.

Nothing promoted, no rule row added, config and allowlist untouched. The null grows: 0 of 90 → 0 of 114 → 0 of 138.

feat(rules): the promotion gate prices fills per product, and the restatement that fires it (#335) (#686)

Closes #335. It waits for “a thin-asset candidate to reach the gate” — measuring the universe answers a sharper question than the trigger asked.

Every document in this repo prices fills at a floor nobody reaches

slippage_for_quote_volume reaches its 5bp floor only at the model’s $500M/day anchor. Over the 24-asset universe’s own cached candles:

assets at the floor0 of 24
cheapestBTC-USD, 5.5bp — 1.1×
dearestTON-USD, 183.8bp — 36.8× (the cap)
median≈ 10×

#335 names “the STX/CRO-class 1.15–1.30× floor entries” as the live example of a thin-asset candidate. The live example is the entire universe.

What it costs

240 trials — five rules at shipped defaults × 24 assets × both regimes, run in one driver so the A/B is internally consistent rather than compared across documents.

ruleflatper-productdelta
turtle_breakout0.3360.267−0.063
rsi_meanrev0.2610.175−0.082
pullback_continuation0.0420.012−0.021
cusum_event0.3430.243−0.080
triple_barrier0.3380.237−0.092

Median across 120 cells: 0.309 → 0.219. Every one of the 120 deltas is ≤ 0.

The comparison that matters

That 0.090 median overstatement sits next to the other number measured this week: the triple barrier’s better exit bought +0.033 of gross profit factor.

The error in the cost model was 2.7× larger than the best genuine improvement any rule change produced. Every strategy comparison in this repository has been made through a lens that mis-priced execution by more than the differences being compared.

The corpus’s only positive cell dies

turtle_breakout on WLD-USD: 1.061 → 0.626, at 24.2× the rate it was priced at, already below the trade floor at n=58. Zero of 120 clear PF 1.0 per-product.

Why the deferral was safe, and why it isn’t now

#259 deferred on the reasoning that the correction is conservative-only — real cost is higher, a corrected profit factor can only fall, and per-product pricing can never manufacture an edge. Confirmed: all 120 deltas are ≤ 0.

What it doesn’t survive is the magnitude. A correction assumed to be a rounding adjustment is worth 0.090 of median PF and kills the only positive cell in the corpus. A gate pricing promotion decisions at the best rate the model can produce is not being conservative.

The change

rules backtest / rules promote now price per product, from the product’s cached ONE_DAY bars by the same one definition simulate.slippage_assumptions uses — deliberately not the rule’s own trading granularity, because median_daily_quote_volume is a per-bar median despite its name, and an hourly figure handed to a daily-anchored model reports every asset as maximally thin. (triple_barrier.per_product_round_trip can’t read daily bars — a pure rule has only what it’s handed — and scales explicitly instead. Both say so where they do it.)

No daily bars falls back to the flat floor and is flagged as a fallback, never presented as a measured verdict. ResolvedBacktest carries both, so a front-end can print the assumption beside the number it produced.

Verification

7 mutants, 7 killed — two were the call sites. Removing slippage_pct= from _backtest_rule or from backtest_resolved left every helper test green, because the helper being right proves nothing about anything using it.

Full suite 5247 passed / 3 skipped; ruff and mypy clean. Ledger verifies at 93 rows.

Deliberately not done

The older documents keep their flat-priced figures. Their verdicts don’t move — the correction only pushes them further from 1.0 — and re-running them isn’t free. A pointer note is worth more than the compute.

The null is unchanged and firmer: 0 of 138, now at honest cost.

fix(docs): the rail count drifted to five different answers in one commit (#687)

guards.check implements twenty rails — 1–14 and 16–21, rail 15 retired. At v0.13.2 the repository stated that number four different ways at once:

wheresaid
guards.py:267“all twenty rails”✅
README.md:227“the 18 rails”❌ two behind
executor.py:4“eighteen … rails”❌
executor.py:93“eighteen rails”❌
executor.py:1558“eighteen rails”❌
My own sweep caused it

#667 added rail 21 and updated the count by grepping for “nineteen”. That found guards.py, glossary.md, the runbook, launch.md and go-live-runbook.md — and never touched the four places saying “eighteen” or “18”, which had already been stale since rail 19. Fixing the spelling I happened to search for left the ones I didn’t, and nothing failed.

Test first, and it earned it immediately

Writing tests/execution/test_rail_count.py before the fix found five claims where my manual grep had found three — including executor.py’s module docstring, which I’d missed entirely.

The count is derived from the numbered rails in guards.check, and every English and numeric spelling is searched repository-wide. Rail 22 fails the suite instead of leaving documents wrong for three releases.

Dated records are excluded, deliberately

docs/research/2026-08-20-quant-lab-note-cross-verification.md says “eighteen rails” and “four rule families” — both were true when a third party’s note was verified in August 2026. Updating it would silently rewrite a verification nobody re-ran. docs/experiments, docs/presentations and docs/superpowers are out for the same reason: they state what was true when written.

The contiguity assertion is about the set

Not “sorted, unique, starts at 1”. A mutation renumbering rail 21 → 22 passed all three of those — the count held, the order held, 15 was still absent — while a silent gap opened at the top. A gap is the shape this drifts in, so the assertion compares against 1..max minus the retired 15.

Verification

Tests: tests/execution/test_rail_count.py — 2 tests. 4 mutants, 4 killed: README going stale, a docstring going stale, a renumbering that opens a gap, and reuse of the retired rail 15.

Full suite 5249 passed / 3 skipped; ruff and mypy clean.

docs(readme): the cost-distortion finding, rendered from the ledger (#646) (#688)

Partial. #646 also asks for a ~10-second terminal capture with committed generation tooling. That’s unbuilt and the issue stays open for it. This is the research half.

The README already leads with the fee-drag table (shipped earlier). This adds the finding that came out of measuring it properly.

What it says

Every backtest here used to price fills at the floor of keel’s own slippage model — the best case the model can produce, reached only at a $500M/day anchor. 0 of 24 assets reach it. Re-pricing per product moved the median profit factor across 120 cells from 0.309 → 0.219.

profit factor
the cost-model correction−0.090
the best strategy improvement we have measured+0.033

2.7× — derived at render time from two separate hash-chained ledger rows, not typed. A literal would survive either number changing underneath it, which is exactly the trap a headline ratio sets, and a test asserts the ratio is computed.

What it deliberately does not say

Nothing about any other framework.

The tempting version of this section framed crypto backtesting generally as living on fantasy friction assumptions. We have not measured anyone else’s defaults, and an unsourced claim about a named third party is the exact failure this section exists to avoid making about ourselves. #646 forbids competitor naming in the asset on trademark grounds; the stronger reason is that the finding needs no help.

A test asserts six framework names are absent and that the scope sentence — “keel mis-pricing keel, on one venue” — is present.

The headline is narrower than proposed

“Strategy optimization is irrelevant under flat slippage assumptions” generalises from one comparison, one universe, one venue. What’s written instead — “we mis-priced our own execution by more than any strategy change we ever made” — is checkable line by line, and is the more damaging claim precisely because it’s about us.

Two rendering bugs the tests caught

Both invisible in the source, both obvious on the page:

  • textwrap broke the source link mid-URL, so it rendered as literal text.
  • A blank line after every paragraph turned one four-row table into four one-row tables.
Verification

Tests: tests/test_fee_reality_block.py (+6, 16 total). 6 mutants, 6 killed: a hardcoded ratio, a dropped scope disclaimer, a named competitor, a hand-edited README number, a wrapped link, a split table.

Full suite 5253 passed / 3 skipped; ruff and mypy clean.

Regenerate with python scripts/render_fee_reality.py --write; the drift pin fails if the README and the ledger disagree.

chore(release): 0.13.3 — honest cost at the gate (#689)

Version bump only — 8 pyproject.toml files plus uv.lock, the same nine-file shape as 0.13.1 and 0.13.2. No code, no migration; schema stays at v16.

Why cut now

#335 has live consequence. The promotion gate priced every fill at the floor of keel’s own slippage model — a rate reached only at a $500M/day anchor, which not one of the 24 assets keel holds candles for reaches. keel rules promote would have priced a promotion decision at 5bp on an asset whose measured rate is up to 36.8× that.

Nothing is promoting today, so it was never urgent. It’s the kind of thing that matters the first time it isn’t.

The restatement that fired it ships too: 240 trials, median profit factor across 120 cells falling 0.309 → 0.219, and the corpus’s only cell above 1.0 dying at 0.626. The correction is 2.7× the +0.033 the best strategy change ever bought.

Also shipping
#341 / #342Two new rule kinds — cusum_event, triple_barrier — both measured, both null. Registered and untraded: no rule row, no allowlist change, nothing promoted.
#648Enforced 12-hour session lifetime once a remote host is configured; wildcard bind refuses to start without one. Remote access is still not ready — PWA secure-context over a real HTTPS origin is unverified, and docs/remote-access.md says so at the top.
#681doctor reports the update backups it never mentioned. This is the first build that can see them — which is why the 7.0 GB in the live launch folder had to be found by asking.
#687 / #688The rail count (which disagreed with itself in five places at v0.13.2) now derived from guards.check and pinned; the cost-distortion finding rendered into the README from the ledger.
Verification

tests/test_packaging.py::test_every_workspace_package_carries_the_same_version was re-run against a deliberately partial bump before committing this one, and it still fails as it should — the guard that caught the root-only bump at 0.13.0 is alive rather than merely present.

Full suite 5255 passed / 3 skipped; ruff and mypy clean.

After merge: Actions → Release → Run workflow, 0.13.3, desktop: publish-unsigned for the DMG/installer artifacts.

v0.13.2

Voir cette version sur GitHub ↗

Built from 6ca893a3d4e5. Version binds to this hash: keel --version reports keel 0.13.2+6ca893a3d4e5 [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.13.2-py3-none-any.whl
keel versions

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

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

Configure

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

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

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

Desktop app (macOS / Windows)

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

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

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

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

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

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

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

A SHA256SUMS-<platform>-<arch>.txt file per build is attached as well (SHA256SUMS-macOS-arm64.txt, SHA256SUMS-macOS-x86_64.txt, SHA256SUMS-Windows-x86_64.txt) — pick the one matching what you downloaded. If either check fails, do not run it.

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

Other changes

docs(fiqh): long-only is bay’ ma la yamlik, and it is not closed at the venue boundary (#669)

docs/fiqh-basis.md had no section on long-only at all. The rails table covered the prudential rails, rails 18/19 covered the spot-only charter, and the one ruling that is neither — keel never sells what it does not hold — was left implicit in four places that enforce it and named in none.

What the section says

Long-only is a fiqh-derivation, not a charter, and it descends from Ayub Ch 5.4.2 (§65.11) and Ch 6.5.1 (§65.4): bay’ ma la yamlik. That anchor is more direct than riba for this class of failure, and the difference is load-bearing — a sale of what the seller does not hold is impermissible with no interest anywhere in the transaction, so a riba-framed defence does not reach it.

The four enforcement points are named with the source text that carries them: direction: Literal["long"] plus its __post_init__ runtime check (#447), side=Side.BUY unconditional in the engine, rail 10, rails 18/19.

One qualification, stated because §65.4 is invoked here for nearly the opposite of what it was read for. §65.4’s own point is that possession is CONSTRUCTIVE — “what is meant by possession here is the inability to deliver the goods” — which is exactly how exchange-held balances qualify as valid qabd. That lenient reading does not weaken the long-only claim; it sharpens it. In every failure mode below the asset is not there at all, so selling it fails the prohibition on the lenient reading, not merely the strict one.

The hadith is recorded honestly. Hakim ibn Hizam — “Do not sell what is not with you”, Sunan an-Nasa’i 4613 / Abu Dawud 3503 — is marked as not extracted in this repository’s knowledge base. It carries no §N.x row and is not disguised as one. A test pins the disclaimer and its truth: if a source file ever extracts the hadith, the disclaimer has become false and the test says so.

The new open question

The ruling is sound where keel decides and open where it settles:

  • A SELL is never clamped to the account’s available base — fee dust, an unrecorded partial fill (filled_quantity is NULL on every filled live order), or an out-of-band transfer can send an order for more than is held (#667).
  • A resting stop leg can outlive the position that justified it; no sweep cancels a SELL whose position is gone (#668).
  • On a cash account each is a rejection. On a margin-enabled account the venue fills the difference as a short — and there is no cash-account posture check on Coinbase; verify_cash_account exists only on the Alpaca adapter (#666).

Named in the doc rather than left for a reader to discover.

Pins

Four tests, all two-sided, 12 mutants and 12 killed.

The base.py pin matches the field line by regex rather than the file, because __post_init__’s docstring quotes the annotation too and a prose mention is not a contract — an earlier version of this pin passed against direction: str. It also pins the runtime check separately, since the annotation is a promise to mypy and a foreign rule never runs mypy.

The venue-boundary pin is anchored to _sell_base_size’s own stated contract, so when #667 lands this test fails — the doc gets updated with the code, not after it.

Full suite: 5059 passed, 3 skipped. ruff and mypy clean; no new ruff format deltas.

Closes nothing — it records what #666/#667/#668 are for.

fix(execution): a SELL is clamped to what the venue holds, and refused when it holds nothing (#667) (#670)

Closes #667.

_sell_base_size quantized the exit down to the venue’s increment and did nothing else. No SELL path read a base balance — rail 13’s own comment recorded the asymmetry: “SELL intents never fetch a balance (the rail exempts them).”

So keel sold the quantity its ledger believed it held, and the ledger runs high in three ways it cannot see from its own records:

  • a venue that takes its taker fee out of the received base leaves 0.9985 where the order said 1.0000;
  • a partial fill leaves less still, and orders.filled_quantity is written only when the venue’s post-fill status was observable;
  • an operator who moves coins out of the account tells keel nothing at all.

On a cash account each is a rejected exit — a position that wanted out, still in, unprotected. On a margin-enabled account (#666) the venue fills the difference by opening a short. That is bay’ ma la yamlik arrived at by arithmetic, reachable without a single line of the strategy layer proposing one.

Two mechanisms, deliberately split
what it doeswhen
executor._clamp_to_heldshrinks the order to the holdinga position that really exists, just smaller than the books say
rail 21 (base_balance)refuses the orderthe venue affirmatively reports holding nothing

_build_intent already returns None when the ledger says zero, so reaching rail 21 at all means the two disagree. Refusing there sells nothing and traps nothing, because there is nothing to trap.

Neither cancels a protective order — which is why _record_observed_fill_quantity’s refusal to auto-resize stands beside them unchanged. What that refusal declines is cancelling a resting bracket on the strength of a settling snapshot. Nothing here cancels anything; it only declines to ask for more than is there, which no snapshot ambiguity can make wrong.

Balance.total, never Balance.available

The load-bearing line of the change. available excludes base committed to resting orders, and keel’s own bracket commits the entire position — so for exactly the products keel is protecting, available reads ~0. A clamp on that number would shrink every exit to nothing and rail 21 would then veto it, refusing every stop roll and every exit keel has ever placed. total is available + hold; ownership, not encumbrance, is what the prohibition asks about.

It also makes _roll_stop’s cancel-then-place ordering irrelevant to the number. On available the holding would jump from ~0 to the full position across the cancel, and a roll’s fate would depend on which side of that line it was read from. There is a test for this specifically.

Rail 21 fails OPEN on unknown

The deliberate inverse of rails 12/13/17, and the one place in this engine where “unknown is a rejection” would do more harm than the hole it closes. A refused BUY costs nothing; a refused SELL strands a position that wanted out. It is _sell_base_size’s asymmetry table applied to a balance instead of an increment, and the comment says not to fix it into consistency.

Rail 21 joins LIVE_STATE_RAILS, so paper skips it and reports the skip — a paper track record that silently skipped a live-state rail would score a strategy on evidence live trading would have refused.

Two more things

filled_quantity was coupled to the price. One fill <= 0 guard returned before the quantity was recorded, so a venue answering with a filled size and no average price lost the size too — and the size is the half that measures the drift. The quantity observation now runs first. The guard itself is kept, because a zero overwriting a usable price estimate was always the right thing to decline.

Drift is surfaced, not absorbed. Recorded under balance_drift:<product> and reported by keel doctor as balance.drift — WARN, not FAIL, because every cause is legitimate. A log line alone requires an operator to know to grep for it, and the drift outlives the order that discovered it: the next exit is clamped by the same amount.

Costs and behaviour changes
  • One extra get_balances per SELL-ish order. An entry now costs two (rail 13’s quote read, then the bracket’s base read).
  • Two existing tests changed premise, not implementation. test_exit_signal_never_fetches_a_balance asserted get_balances_calls == 0 and was right for as long as the only balance question was rail 13’s. #667 asks a different question of the same endpoint, and it is the exit that must ask it.
Verification

21 mutants, 21 killed. One survivor was worth the exercise: every rail-21 test built its OrderIntent by hand, so dropping available_base= from the intent the executor actually builds left all of them green. The end-to-end tests through execute, place_bracket, scale_out and roll_to_break_even exist because of that survivor.

Full suite: 5081 passed, 3 skipped. ruff and mypy clean across 120 source files.

Note on ordering with #669

The rail count moved from nineteen to twenty in guards.py and five docs, and docs/fiqh-basis.md gains a “rail 21 is the second fiqh rail” section. #669 (the bay’ ma la yamlik amendment) edits the same file on a branch cut from the same base, so whichever merges second needs a rebase — I’ll reconcile them rather than let either land stale.

fix(execution): cancel a resting SELL whose position has left the account (#668) (#671)

Closes #668. Stacked on #670 (targets fix-667-sell-clamp) — the sweep reads executor._held_base, and a second reader of the same venue fact would be two things to keep in agreement.

Why nothing caught this

_clear_resting_bracket fails closed before an exit. reconcile_unbracketed_positions heals a position that has no bracket. Both are position-driven — they start from a tranche and ask what protects it. A protective order whose position has left the account has no tranche to be walked from, so neither can reach it. That is the structural reason the gap existed, not an oversight in either sweep.

sweep_orphan_brackets runs the other way: from keel’s own resting SELLs to the account.

How an orphan appears

The shape is not obvious on a venue with native brackets, so it is worth being concrete:

  • the operator sells or transfers the asset at the venue — keel’s bracket keeps resting against inventory that left without telling it;
  • scale_out and _roll_stop both cancel before they place — a process dying inside that window can leave a replacement resting over a resolved position;
  • a venue without a native trigger-bracket needs two legs, and a filled target leaves a live stop behind. Not reachable on the venue that trades live today, where BracketGTC is one order — and reachable the moment one is not.

Left alone, the market reverses through the stop and the venue is asked to sell an asset that is not there. Cash account: a rejection. Margin-enabled (#666): a short, produced entirely by a missing cancel.

The venue decides, never the ledger

This is a safety property, not a preference. Cancelling a protective order over a position that really exists strips a live holding of its only stop, so the trigger has to be the account’s own statement about itself. keel’s ledger can be stale in exactly the dangerous direction — saying zero while the venue holds the position — and a sweep driven from it would cancel precisely the brackets that were working.

An unreadable balance therefore cancels nothing. A venue that omits empty accounts is saying nothing, not saying zero.

Balance.total, not Balance.available, for the same reason as #667’s clamp: a resting SELL holds the base it commits, so available reads ~0 for exactly the products this sweep looks at. Reading it would cancel every protective order keel has ever placed, on the first cycle, and call the result a fix. There is a test for that specific inversion.

Dust

Strictly below base_increment counts as nothing held — a residue the venue cannot express as a size is not a position. Exactly one increment is the smallest real position there is and keeps its bracket. With no increment known there is no threshold and none is invented: _base_increment_for returns None for a venue error as much as for an unknown product, and a guessed floor would cancel protective orders over a number nobody supplied.

Where it runs, and why there

Last of the three reconciliation passes. reconcile_unbracketed_positions will place a bracket for a tranche whose position left out of band, so sweeping afterwards makes the cancel the cycle’s final word and nothing re-creates within one cycle what it just removed. Running it first would leave a fresh orphan resting for a full day — this deployment cycles once per UTC day.

#667’s rail 21 stops that re-place at source, which makes the churn rare rather than the ordering optional: the rail refuses to place into an empty holding, this cancels what already rests, and neither subsumes the other.

What it deliberately does not do

The tranche is left open. Closing it would book a realized outcome at a price nobody observed — what the position was worth when it left the account is a question only an operator can answer. doctor’s new bracket.orphan finding and the existing ledger.unbooked_exit both surface it.

It also never raises into the cycle: per-row isolation, an accepted-but-unsettled cancel is not recorded as done, and a refused cancel leaves the local row untouched (a venue that refuses may have already filled it).

Verification

11 mutants, 11 killed — including the wiring (a sweep nothing calls reports nothing) and its position in the cycle, which a presence-only test would miss.

Full suite: 5105 passed, 3 skipped. ruff and mypy clean.

docs(fiqh): the venue boundary is closed; what is open is the premise underneath it (#672)

Follow-up to #669/#670/#671, which all merged cleanly and left docs/fiqh-basis.md internally inconsistent.

What was wrong

#669 landed a “Known open questions” bullet describing the venue-boundary gap. #670 and #671 then closed it. Git merged all three without a conflict, so the doc now claims — three paragraphs below the section describing the machinery that fixes it — that:

  • a SELL is never clamped to the account’s holding (now false — _clamp_to_held)
  • filled_quantity is recorded nowhere on this deployment (now written before the price guard)
  • no sweep cancels a SELL whose position is gone (now false — sweep_orphan_brackets)
The pin that failed to fire

This is the part worth recording. #669 anchored that section to _sell_base_size’s docstring sentence — “it can only ever make an exit MORE likely to be accepted” — reasoning that #667 landing would break it and force the doc forward with the code.

It didn’t. #667 clamped at intent construction and left that docstring standing, correctly, because the function’s own contract never changed. So the pin held while the paragraph it guarded went stale.

A proxy is only a pin while the thing it stands for and the thing it matches move together. That one stopped, silently, and the test stayed green through exactly the change it existed to catch.

What replaces it

The question that actually remains open, and it is a better one than the bullet it retires.

_clamp_to_held, rail 21 and sweep_orphan_brackets all fail open on an unreadable balance — deliberately and correctly, since refusing a SELL over a balance endpoint that went quiet would strand positions that wanted out. So a venue that stops answering does not produce a refusal; it produces the old behaviour, an exit sized from keel’s own ledger.

That is survivable only while the account cannot go short, and keel does not check that it cannot. verify_cash_account exists on the Alpaca adapter alone. The layered defence is sound whenever the venue answers, and rests on an unverified premise whenever it does not — which is #666.

Re-anchored

Two-sided against the premise itself rather than a sentence about it: the coinbase adapter must still have no posture read, and the alpaca adapter must still have one. When #666 lands, this test fails because the fact changed, not because someone reworded a paragraph.

4 mutants, 4 killed. Full suite: 5109 passed, 3 skipped. ruff and mypy clean.

chore(release): 0.13.2 — the venue boundary, closed (#673)

Version bump only — 8 pyproject.toml files plus uv.lock, the same nine-file shape as the 0.13.1 bump. No code, no migration; schema stays at v16.

What 0.13.2 carries to the deployment

~/keel is on 0.13.1 (9c11ead), which is exactly the commit #669–#672 were cut from. Everything below is currently unreleased:

#667_clamp_to_held reduces a SELL to what the venue reports holding — down only, never up, unchanged when unreadable. Rail 21 refuses an order against an affirmatively empty holding.
#668sweep_orphan_brackets cancels a resting SELL whose position has left the account — order-driven, where every existing sweep is position-driven and so could not reach it.
#669/#672docs/fiqh-basis.md records rail 21 as the second fiqh rail, anchored in bay’ ma la yamlik rather than riba. Rail count nineteen → twenty across guards.py and five docs.

All three mechanisms read Balance.total, never Balance.available — a resting bracket holds the base it commits, so available reads ~0 for exactly the products keel is protecting.

keel doctor gains balance.drift and bracket.orphan, both WARN. The live deployment has neither finding today; they exist so the next divergence isn’t found by asking an unrelated question.

Still open

#666. All three mechanisms fail open on an unreadable balance — correctly, since refusing a SELL over a quiet endpoint strands positions that wanted out. That is survivable only while the account cannot go short, and verify_cash_account exists on the Alpaca adapter alone. tests/test_fiqh_basis.py pins that two-sided, so it fails when #666 lands.

Verification

Full suite 5109 passed / 3 skipped; tests/test_packaging.py green, which is the check that catches a root-only bump with stale sibling pins.

After merge: Actions → Release → Run workflow, 0.13.2, desktop: publish-unsigned for the DMG/installer artifacts. The head -1 closed-pipe bug that killed every desktop leg on v0.13.0 was fixed in 0.13.1 and is on main.

v0.13.1

Voir cette version sur GitHub ↗

Built from 9c11ead4d0b6. Version binds to this hash: keel --version reports keel 0.13.1+9c11ead4d0b6 [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.13.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

fix(cli): a reader that hangs up is not a crash (#663) (#664)

Closes #663. Unblocks the 0.13.0 release.

The Windows leg died mid-render:

File "keel\commands\brokers.py", line 396, in brokers_list
    click.echo(line)
OSError: [Errno 22] Invalid argument
Exception ignored while flushing sys.stdout: OSError: [Errno 22]

Not encoding, and nothing in the 0.13.0 payload. release.yml ran brokers list | head -1; head takes one line and exits, closing the pipe, and on Windows the next write raises OSError with EINVAL — errno 22, not 32 — which no except BrokenPipeError catches.

Why now: brokers list went from 4 lines to 35 when #624 added the readiness block. With four the process finished before head exited; with thirty-five it doesn’t. v0.12.2 passed the identical check for that reason alone.

Red herring, recorded so nobody re-chases it: brokers.py does emit a non-ASCII " · " (U+00B7). It’s in v0.12.2 too, whose Windows leg passed.

Two halves

The workflow captures before filtering, at both call sites. Every other check in that file already did — these two were the exceptions. The second, brokers list | grep -qi 'fake', carries the same trap and is masked only because fake is absent so grep reads to EOF; it would have failed exactly when it found the thing it looks for.

The app grows keel.cli.main, and both entry points route through it — the console script and the frozen bundle, which is the build that failed. It catches BrokenPipeError and OSError with EPIPE/EINVAL, and deliberately not a blanket OSError: a disk filling mid-write raises ENOSPC and an operator needs to hear about it.

The devnull redirect isn’t decoration — without it the interpreter flushes the dead stream at shutdown and raises a second time, after the handler ran. That’s the trailing Exception ignored line above.

One of my own pins failed its mutations first

The end-to-end brokers list | head -1 test passed with the handler deleted and with the devnull redirect deleted. On POSIX, 35 short lines fit the 64 KiB pipe buffer, so the write never fails and the handler is never reached. It was decorative.

Rewritten to inject the failure, with the real path moved to a subprocess — run in-process the redirect destroys pytest’s own captured stdout (OSError: [Errno 9] from capture.py), which is the fix working as designed.

All four mutations now fail:

mutation
EINVAL droppedthe original bug
blanket except OSErrorwould swallow ENOSPC
handler removedtraceback returns
devnull droppedthe trailing shutdown failure returns

test_freeze gained the reason the frozen entry must call main and not cli — calling cli directly leaves the one build without the handler, and that build is the one that died.

Honest coverage note

The Windows EINVAL path is exercised by constructing the OSError, not by a real closed pipe: this suite runs on Linux and macOS, where errno 22 never occurs. Stated in the module docstring rather than implied.

Verification

5111 passed, 3 skipped · ruff check clean · mypy clean (379 files) · release.yml strict-parses under GitHub’s own rules.

chore(release): 0.13.1 — the desktop legs, all three of them (#665)

v0.13.0’s release job succeeded — wheels and config.yaml are published and keel update works. Then all three desktop legs failed, so the release is missing two DMGs, the Windows installer and zip, and the three SHA256SUMS-* files. The tag is immutable and correctly refused a re-dispatch, so the fix ships as 0.13.1.

Why all three failed — not what the Windows traceback suggested

The smoke step ran brokers list | head -1. head exits after one line and closes the pipe, and #624 had grown that command’s output from 4 lines to 35, so the write now lands after the reader is gone.

legsymptom
windows-latestloud — OSError: [Errno 22] Invalid argument, full traceback
macos-14 / macos-15-intelsilent — output ends at ok: 7 keel distributions, then exit code 1

The macOS legs are the same bug wearing a POSIX face. The step runs under bash --noprofile --norc -e -o pipefail, so the broken pipe killed keel, pipefail took the pipeline’s failing status, and -e aborted the step with nothing printed — the failure was a signal, not an exception.

Which half of #663 was actually load-bearing

main()’s closed-stdout handler exits 141 — and 141 is still non-zero, so under pipefail it would have aborted the step exactly the same way.

The workflow half is the fix: capture, then filter, so no reader hangs up at all. The handler is still worth having — keel brokers list | head -1 is something an operator types and it shouldn’t traceback — but it’s the smaller half, and #663 stated that ordering backwards. Only these logs showed it.

main now pipes binary output nowhere in release.yml, at either former call site — including the grep -qi 'fake' one, which carried the same trap and was masked only because fake is absent so grep read to EOF.

The bump

Every workspace pyproject.toml and every exact-version sibling pin moves, per the lesson from the 0.13.0 bump where the root alone left the workspace disagreeing with itself (test_workspace_siblings_are_pinned_to_the_exact_version).

uv_build>=0.10.4,<0.13.0 is the build backend constraint and is deliberately untouched.

Verification

5111 passed, 3 skipped · ruff check clean · mypy clean (379 files).

After merging

Actions → Release → Run workflow, 0.13.1. If the desktop artifacts are wanted on the release rather than as workflow artifacts, set desktop to publish-unsigned — the default build smoke-tests them without attaching.

v0.13.0

Voir cette version sur GitHub ↗

Built from c5ee7e3d7773. Version binds to this hash: keel --version reports keel 0.13.0+c5ee7e3d7773 [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.13.0-py3-none-any.whl
keel versions

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

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

Configure

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

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

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

Other changes

fix(web): the worker was holding the door shut against the only key (#604)

What you hit

Four symptoms, one cause.

keel serve prints http://127.0.0.1:8765/?token=.... The server exchanges that token for the session cookie on that navigation — do_GET reads query["token"] and answers with Set-Cookie — and every /api/* read afterwards is gated on the cookie by _admitted.

That is the request #538’s service worker was answering out of its cache:

if (request.mode === "navigate") {
  event.respondWith(caches.match(SHELL, ...).then((hit) => hit || fetch(request)));
}

The server never saw the token. The shell loaded from cache, every read it made was refused, and the page said “Not authorised — open the address keel printed when it started” to someone who had done exactly that.

The other three — no logo, keel not clickable, no light/dark icons — are the same cache. The brand block and the theme toggle shipped in #597, which is in v0.12.2; a browser holding the previous build’s shell shows the 0.12.1 header.

The deadlock, which is the part worth naming

The update offer that would replace the worker lives in a footer main.js fills only after a successful boot read — and the boot read is what is being refused. A cached worker whose shell cannot authorise cannot be replaced by the mechanism built to replace it.

The fix

One branch: a navigation carrying the session token declines to the network.

A bare return, not respondWith(fetch(...)) — declining hands the request to the browser exactly as if no worker were installed, redirect and cookie handling included.

No cache fallback there, deliberately. An unreachable server should show the browser’s own failure page; a cached shell would look like it worked and then refuse every read, which is the failure this exception exists to prevent.

The pin

Parses the navigation branch, asserts the token check precedes the cache lookup, and asserts the worker’s parameter name equals the one server.py reads. Mutation-verified three ways — guard removed, guard moved after the cache lookup, parameter renamed on the server side alone. All three fail it.

4438 passed, 3 skipped.

You need one manual clear

Your browser still holds the pre-fix worker, and that worker is what would intercept the navigation that installs its replacement. Once, in the browser you use for keel serve:

DevTools → Application → Service Workers → Unregister, then reload with the token URL. Or right-click reload → Empty Cache and Hard Reload. A private window also works as a check.

After that the token exchange lands, and the 0.12.2 header — mark, clickable brand, theme toggle — appears.

Summary
  • Adds docs/decisions/0004-monetisation-not-now.md, recording keel’s answer to the five monetisation options Jesse’s founder evaluated publicly — donations and token-issuance rejected outright, exchange sponsorship and SaaS deferred on the sequencing argument (Jesse open-sourced 2018 → public 2020 → monetised 2021 with an earned audience; keel is six weeks old with 3 stars), a closed live-trade plugin rejected specifically because keel/execution/ is the compliance enforcement itself, not a bolt-on feature.
  • States the affiliate-link conflict as a permanent no, not a “not yet”: keel’s headline finding is that fees are the binding constraint, and referral revenue paid per user routed to a venue is a direct stake in the same fee schedule the project’s credibility depends on assessing without an incentive.
  • Records four triggers that would reopen the deferred parts, framed in adoption and demonstrated demand rather than revenue — per the issue’s own ask.
  • Registers 0004 in docs/decisions/README.md’s index, and backfills the missing 0003 entry found in the same file.

Closes #603.

Test plan
  • uv run pytest -q — 4437 passed, 3 skipped in 148.71s (0:02:28)

fix(docs): the wheels’ stated floor was 3.11, the wheels’ actual floor is 3.14 (#595) (#607)

The falsehood

#546 raised requires-python to >=3.14 before v0.12.0 shipped, so every published wheel since v0.12.0 has carried that floor. docs/desktop-install.md and the v0.12.0 release notes’ Desktop section never got the memo — both said “the wheels declare >= 3.11”, which was true two releases back and false of every release since. Someone on 3.11–3.13 who trusted that line and ran the terminal installer got refused by pip mid-install, having followed the docs exactly.

README.md and packaging/macos_app.sh’s DMG note carried the same stale 3.11, and — worse — scripts/install.sh’s FALLBACK_FLOOR (the constant it enforces when it can’t fetch the release’s own pyproject.toml) was also still "3.11", so even the installer’s own fallback path enforced the wrong floor.

The fix
  • README.md, docs/desktop-install.md, packaging/macos_app.sh: >= 3.11 → >= 3.14.
  • scripts/install.sh: FALLBACK_FLOOR="3.11" → "3.14", comment updated to match.
  • .github/workflows/release.yml: the Desktop section of the release-notes template now says “Python 3.14+.” This fixes the next release’s notes — the already-published v0.12.0 GitHub Release is left alone; retro-editing a published release is its own kind of falsehood.
  • docs/desktop-install.md also dropped a now-false claim that the source-checkout path “develops on a newer Python than the wheels require” — that’s no longer true now that both floors are 3.14.
The new pin

tests/test_install_script.py already cross-checks README/desktop-install.md/macos_app.sh/FALLBACK_FLOOR against each other (#557) — and stayed green throughout, because all four agreed on the same wrong number. Nothing checked them against the one place that isn’t hand-maintained: pyproject.toml’s requires-python.

Added test_the_wheels_floor_stated_in_release_facing_docs_matches_requires_python to tests/test_python_floor.py. It parses requires-python fresh from pyproject.toml (never hardcodes "3.14") and fails if any of the five places above states a different floor.

TDD: wrote the test first, watched it fail against the stale docs:

AssertionError: README.md states the wheels floor as '3.11', but pyproject.toml's
requires-python is '>=3.14' (floor '3.14') -- this is the exact drift #595 was filed over

Then fixed the docs and watched it pass.

Mutation evidence: after the fix, reintroduced 3.11 into docs/desktop-install.md’s >= 3.14 line, reran — the same assertion failed for the same reason (states the wheels floor as '3.11', but ... requires-python is '>=3.14') — then restored the file and reran green. A pin that’s never been seen to fail is not a pin.

…truncated — full description in #607.

docs(spec): the web UI spec describes a shipped system, not a target (#601) (#608)

Part one of #601, and it turned out to be a different job than the issue expected.

The premise was stale

#601 asked for the unmerged local branch docs-web-ui-spec to be landed, on the reading that its 437-line design document had never reached main even though the rewrite it specifies had shipped. That reading is wrong. The document landed at #531, and #538 amended its § “JS file structure” in place afterwards. The local branch is behind main, not ahead of it — git diff origin/main docs-web-ui-spec over that file is eighteen lines, all of them main carrying the #538 amendment the branch lacks. There was nothing to land and nothing to rebase.

The same staleness explains the issue’s other expectation. #601 warns of an in-flight keel/commands/research_console.py on that branch to reconcile with; that file is an artefact of the branch’s old base, and moved to keel/corpus.py when #541 deleted the console layer around it. There is no conflict to reconcile.

What was genuinely left undone

The second half of the ask: checking whether the document’s claims still match what shipped. They did not.

A reader arriving at the page now reads “Today keel serve renders HTML in Python (keel/web/render.py, 872 lines)” as a fact about the installed code, goes looking for that file, and finds nothing. That reads as documentation rot, when what actually happened is that the document’s argument succeeded and the file was deleted at #540 — exactly as its own § “What this deletes” table asked. Same for keel/commands/tui.py (#541) and keel/commands/help_console.py.

So the status line now says shipped and names the seven issues that shipped it, and a new paragraph up front tells the reader which citations below point at a state that no longer exists. The present tense of the body is deliberately left standing. It is the tense the argument was made in, and rewriting it into the past would turn a record of why into a description of what — which is what the code is for. This is a historical design record and it is not rewritten wholesale.

Four assertions are corrected where they had gone false rather than merely dated:

…truncated — full description in #608.

feat(research): keel research — one front door over the thirteen evidence modules (#601) (#609)

Closes #601.

The issue’s premise was half wrong, and the correction is the design

#601 says “thirteen modules with no front door.” Six of them already had one. keel trials fronts ledger.py (record/list/verify), cscv.py+matrix.py (pbo), deflate.py, montecarlo.py and walkforward.py; keel rules lookahead fronts bias.py. Only six were genuinely unreachable: significance.py, cts_factors.py, independence.py, throughput.py, tuning.py, pooled_review.py.

So the real gap was never “no code path.” It was no single place that says here is the evidence toolkit — the tools were scattered across two command groups and 30+ ad-hoc drivers under docs/experiments/, discoverable only by reading source. keel research is that place, and it is three things:

  1. keel research index — every module, the question it answers, what it cannot answer, and the command that runs it. --json, and --module NAME for one entry.
  2. Six new subcommands for the six modules that had none.
  3. Five aliases — pbo, deflate, monte-carlo, walk-forward, lookahead are the same click command objects registered a second time, never copies. keel research pbo and keel trials pbo are byte-for-byte the same code. A front door that reimplements is a front door that drifts the moment one copy gets a bugfix the other doesn’t.

No new statistics anywhere. Every number traces to a function already in keel/research/; the command layer assembles inputs, calls in, and prints what comes back, reusing each module’s own render_family/render_report rather than a second renderer that could drift. keel/commands/research.py’s docstring cites ADR 0003 per its standing rule 3.

A refusal is a result, not an error

#601’s second bullet, applied everywhere — including to code that predates it. keel trials deflate, pbo, monte-carlo and walk-forward each had a path where the ledger was readable, the request well-formed, and the evidence simply could not answer it — and all four reported that as click.ClickException: exit 1, on stderr, indistinguishable from an operator typo. Those are now printed on stdout at exit 0.

The line drawn, and held: an operator error means the request was wrong (a rule id that doesn’t exist, a db that won’t open, a broken ledger hash chain — that last is tampering, not insufficient evidence); a refusal means the request was right and the evidence cannot answer it. Only the second became a result. rules lookahead’s exit-1 on an actual lookahead finding stays loud on purpose — a definitive finding is the opposite of a refusal.

Observable today:

$ keel research pooled-review --db /nonexistent.db          # operator error
Error: pre-registered profile db(s) not reachable read-only   → exit 1, stderr

…truncated — full description in #609.

feat(execution): scale_out resizes the bracket it stands beside, and books the half it sold (#502) (#611)

Closes the remaining half of #502: scale_out’s two prerequisites, and #446’s exit-side sibling in the same pass.

bracket resize

scale_out called _run_order directly — the pipeline BELOW execute — so it never reached _clear_resting_bracket, whose own docstring says it lives where it lives “so every SELL path — the rule exit today, scale_out or any future one — gets it by construction”. It did not. The resting native bracket commits the ENTIRE base position, so on spot the base is locked and the partial sell is rejected; if it filled, an oversized bracket was left able to sell inventory no longer held.

The sequence is now crash ledger → cancel → sell → book → re-place at the remainder.

The ledger goes first, before any venue call — #519’s pattern, the same unbracketed: key place_bracket and _roll_stop use, deliberately not a new one. It carries the remainder as its qty, and that is correct in both crash windows precisely because the sweep does not read that number: reconcile_unbracketed_positions sizes from the TRANCHE and takes only the levels from the record. Die before the sell → the tranche still says the full size → the full position heals. Die after the booking → the tranche says the remainder → the remainder heals.

Re-placing smaller is the genuinely new port-consumer behaviour. _roll_stop re-places the same qty at a new price; this re-places the same price at a new qty. BracketGTC already expressed the order.

Three refusals, all before the venue is touched: non-positive qty; qty >= held (a full exit belongs to execute’s EXIT path, which also retires position_rule: and state this function does not own); and no recorded open_stop/open_target — without levels there is nothing to re-place, so cancelling would leave the remainder naked with nothing in the ledger saying so.

partial trade_outcomes

Schema v13 adds realized_qty / realized_proceeds / realized_fees to positions, and reduce_position is the positions.qty UPDATE that did not exist. qty now means what is STILL HELD — which is how the sweep and exit_policy already read it.

…truncated — full description in #611.

feat(trade-scope): the venue trade-scope record, its backfill, and keel scope attest (#233 PR1) (#612)

Issue #233, PR1 of the accepted design. The record and the ceremony, no enforcement. Nothing in this PR can veto an order; rail 20 arrives in PR2 (feat-233-rail-20).

Why

A credential that reads fine is not evidence it can trade. ROBINHOOD_API_KEY was well-formed, every read succeeded, /accounts/ reported is_api_tradable: true, and the first live order came back 403 {"detail":"You do not have permission to perform this action."}. The key was read-only. A venue gated on “are there keys in .env” would have shown it as available and found out at the only moment that costs money.

The cost of that afternoon was never the 403. It was that nothing recorded it, so the next run could not tell a read-only key from a working venue.

Two writers, because attestation alone would have been wrong here

venue_trade_scopes holds one row per venue: unverified → attested (a human’s claim) → confirmed (the venue accepted a placement) or refuted (the venue refused one on permission grounds).

Attestation alone is the mechanism that would have failed in the motivating case: the operator who minted that read-only key believed it was a trading key and would have attested --trading in good conscience. keel/commands/setup.py’s OFF_VENUE doctrine already names that failure — a green check verifying nothing is worse than an honest manual step. So the venue is the second writer and it overrules the first. A confidently-wrong attestation costs exactly one refused entry: a 403 that moves no money.

may_place_live_entry() is driven by state alone, never by refuted_ts. Re-attesting over a refuted record is how an operator reports a rotated credential, and the record deliberately keeps refuted_ts so doctor can still say “you re-attested a venue that refuted a credential on <date>”. Had the predicate vetoed on refuted_ts is not None, the one path that recovers trade scope would never unblock it.

No TTL, deliberately

subscription.py expires because a subscription is user-asserted with no observation channel. Trade scope has one: the venue re-confirms it on every accepted placement. There is no silent drift for a clock to catch, so a due date here would be ceremony with nothing behind it to expire.

The migration is the safety-critical part

Without a backfill, upgrading would hand the running Coinbase deployment — which trades unattended, daily — a database with no row for its venue, and PR2’s rail 20 would veto its next live ENTRY on a venue that has been working all along.

So v13 reads the orders audit log: mode = 'live' AND status <> 'rejected'. executor.py writes status = 'rejected' only when the broker REFUSED the placement, and a rails veto writes no row at all (the insert happens after the guard gate), so any other status means the venue ACCEPTED it. That is proof supplied by the venue itself, which is why the backfilled row is confirmed rather than attested.

…truncated — full description in #612.

feat(web): the chart gains a cursor legend, a trade highlight, and a local export (#602) (#613)

Summary

Closes #602’s “useful half”: the equity curve (chart.js::equityChart, the only chart keel draws — there is no OHLC/candle data and no second pane) gains a cursor legend, a trade-hover highlight, wheel-zoom + drag-to-pan with a “Reset view” button, and a local “Save as image” export. Full reasoning is in the commit message; the short version of the judgment calls:

  • “Synchronized cursor across stacked panes” doesn’t apply — keel has one pane, not Jesse’s multi-chart layout. Noted, not faked.
  • “Highlight its orders” — keel’s journal has no order-level breakdown of a closed trade. Read as highlighting the two equity points (running total just before/after the trade closed) that bound the trade’s contribution to the curve — the nearest thing the data model has to an entry/exit pair.
  • New wire key: entries[].point_index (bare string, None for a row the curve drew no point for) — the join between a journal row and its point on the curve, since build_equity_curve skips rows with no recorded net. Mutation-verified in test_payload.py.
  • Arithmetic placement: chart.js/render.js stay in _DERIVATION_FREE (zero arithmetic, zero .value reads) — all pointer/zoom/canvas math lives in main.js, which already owns “interface arithmetic.” chart.js gained two placement-only exports: showTradeAt, highlightTrade.
  • Non-colour signal: a losing trade’s highlighted segment is dashed and its end markers point down (vs. solid/up for a gain) — colour is never the only signal, matching #532’s ▲/▼ precedent.
  • Zoom survives the 15s poll: rebuildInto replaces the whole view on every poll/tick, so the current viewBox is kept outside the DOM and re-applied there — same mechanism <details> open-state already uses.
  • Save as image, no data: URL: _STATIC_CSP has no img-src override, so img-src falls back to default-src 'self' and refuses data:. Export clones the live SVG, inlines computed style, serialises to a Blob, decodes via createImageBitmap (no URL, no fetch), downloads via a blob: URL.
  • Keyboard access: svg.curve is now tabindex="0"; arrow keys pan, +/- zoom, 0/Home resets. Reset-view/save-image are plain <button>s (free).
Test plan

…truncated — full description in #613.

feat(guards): rail 20 vetoes a live entry on a venue nothing has proven (#233 PR2) (#614)

Stacked on #612 (PR1). Base is feat-233-trade-scope, so the diff here is PR2 alone. It retargets to main automatically once #612 merges.

PR1 built the record. Nothing read it. This is the half that makes it load-bearing.

Shape, borrowed rather than invented

Entries only — rail 17’s doctrine. The reflex is to gate both sides, and it’s wrong: a read-only credential can’t sell either, so vetoing the exit protects nothing the venue’s own refusal wouldn’t, and it costs the attempt — the one thing that produces evidence. A position keel refuses to exit is worse than one the venue refuses, because only the second leaves a 403 for the record to learn from.

Fails closed on a missing record. may_place_live_entry() lives on the record; the rail never re-derives the decision from state fields. One place decides what the four states mean, so a state added later can’t be silently treated as permission.

Venue-keyed the way rail 14 keys it. In LIVE_STATE_RAILS, so paper skips and reports it — a rail that vanishes in paper is one an operator forgets before the day it matters.

The refuted message is distinct and names refuted_reason. An operator who attested in good conscience and was refuted needs the venue’s own words, not a generic veto — that gap between belief and evidence is the entire reason this rail exists.

doctor

trade_scope_findings beside attestation_findings. CONFIRMED is OK. ATTESTED+TRADING is OK but says “unconfirmed operator claim” out loud — that is precisely the state the 2026-08-19 Robinhood credential was in, and it read as healthy. A separate WARN fires when refuted_ts is set but the current state isn’t REFUTED: someone re-attested over a refutation, legitimate when credentials were replaced and worth saying when they weren’t.

Six pins, each broken to RED and restored
mutationobserved failure
fail-closed guard neuteredAttributeError on the None record
may_place_live_entry() ignoredread-only and refuted both stopped vetoing
is_buy droppedthe SELL-doctrine test caught the exit veto
dropped from LIVE_STATE_RAILSpaper stopped reporting while still not vetoing — which is why that test asserts both halves separately
venue hardcoded to coinbasethe alpaca-bound test saw the wrong venue read
doctor WARN gated offthe re-attestation finding vanished
One correction

The brief claimed check()’s “eighteen rails” docstring was already wrong. It wasn’t — rails 1–12 and 16–19 is exactly eighteen. There was no pre-existing drift. It reads nineteen now because rail 20 exists, not because it was ever wrong.

Verification

4543 passed, 3 skipped · ruff clean · mypy clean (356 files)

…truncated — full description in #614.

docs(readme): the MCP server gets a front door (#617)

Closes #600.

What was stale

The issue’s premise checked out against origin/main as of this PR: docs/mcp-server.md fully documents keel’s read-only MCP server (eight tools in keel/mcp/tools.py), and README.md mentioned MCP zero times across its 216 lines (212 at issue-filing time; it grew by four lines for docs/research-toolkit.md in the interim — the zero-mentions fact was unchanged).

What changed
  • Opening description — a new sentence naming keel’s “fourth way to read all of this”: keel mcp over stdio, linked to docs/mcp-server.md, stating the boundary in the same breath (cannot attest, promote, arm autonomy, or place an order — by construction, not promise).
  • Feature list (## How keel works) — a new bullet alongside Rules/rails/Screening/Confirm-vs-autonomy, naming all eight handlers (doctor, capabilities, profiles, orders, veto_log, purification, trials, reports), where they live, and the same enforced boundary (query_only + the write-verb scan in tests/mcp/test_readonly.py).
  • Documentation map — docs/mcp-server.md gets an entry like every other doc.
  • docs/mcp-server.md — one line back to the README (../README.md#how-keel-works) so the cross-link runs both ways and the two pages can’t drift apart in only one direction.
Boundary, stated as enforced not intended

Per the issue’s “What NOT to copy” section: keel’s MCP must never grow the ability to author or promote a rule, attest, arm autonomy, or place an order — that would let an assistant route around both the promotion ladder and the compliance gate. The README states this the same way docs/mcp-server.md already does: it’s a query_only guard and a static write-verb scan (tests/mcp/test_readonly.py), not a policy promise.

Tests

Four new pins in tests/test_readme.py, written and run red first:

AssertionError: the README's MCP section must name the 'doctor' handler
AssertionError: the Documentation map must list docs/mcp-server.md
AssertionError: docs/mcp-server.md must link back to the README -- a one-way link is how the two drift

All four went green only after the corresponding doc edit — mutation-verified by construction (TDD red->green, not a post-hoc deletion).

Results
  • uv run pytest -q: 4571 passed, 3 skipped in 153.38s
  • uv run ruff check keel tests packages: All checks passed!
  • uv run mypy: Success: no issues found in 359 source files

fix(release): name the real per-platform SHA256SUMS files (#618)

Closes #605.

Premise checked against origin/main

Confirmed stale: .github/workflows/release.yml’s Checksums step has always named its output per matrix leg — CKSUM_FILE="SHA256SUMS-${{ runner.os }}-${{ matrix.arch }}.txt" — deliberately, per a comment right above it (“Per-leg name, not SHA256SUMS.txt: three matrix legs uploading one shared name under —clobber would leave only the LAST leg’s sums on the release”). Both docs/desktop-install.md and the release-notes template composed inside release.yml’s own release job still said “SHA256SUMS.txt is attached as well” (singular). v0.12.0’s actual attached files: SHA256SUMS-macOS-arm64.txt, SHA256SUMS-macOS-x86_64.txt, SHA256SUMS-Windows-x86_64.txt — matching the issue exactly.

What changed
  • docs/desktop-install.md — names the three real files, explains why there’s no combined one, and tells the reader to pick the file matching their platform (with a sha256sum -c example).
  • .github/workflows/release.yml’s “Compose release notes” step — same fix, same wording style.
  • tests/test_desktop_packaging.py — two new tests that compute the expected filenames from the real source: the desktop job’s matrix (strategy.matrix.include, parsed with the strict YAML loader this file already uses) and the Checksums step’s own naming template (extracted via regex, with an extra pin that the template’s shape itself hasn’t silently changed). Both docs copies are checked against that computed set, and against the absence of the literal SHA256SUMS.txt.

This is the same family of defect as #595 (one fact, five stale copies) and follows the same fix discipline per that issue: read the real source rather than hardcode the literal being checked, so a future change to the matrix or the naming template makes the test recompute its expectation instead of silently passing against stale names.

Mutation verification (each reverted after)
  • Reverted docs/desktop-install.md to the old singular wording:
    AssertionError: docs/desktop-install.md must name 'SHA256SUMS-macOS-arm64.txt' -- it is one of the files the release workflow actually attaches
  • Reverted the release-notes echo lines the same way — the sibling test failed identically against the “Compose release notes” step.
  • Changed CKSUM_FILE’s template to use underscores instead of hyphens:
    AssertionError: the checksum filename template changed to 'SHA256SUMS_${{ runner.os }}_${{ matrix.arch }}.txt' -- the docs and the release-notes wording may need to change with it, not just this test
    (both doc-facing tests failed too, since neither doc names underscore-separated filenames)
Results
  • uv run pytest -q: 4569 passed, 3 skipped in 151.74s
  • uv run ruff check keel tests packages: All checks passed!
  • uv run mypy: Success: no issues found in 359 source files

feat(broker-api): the venue falsifies what the operator attested (#233 PR3) (#619)

Closes half of the remainder of #233. PR1 (the record, migration, keel scope attest) and PR2 (rail 20, doctor) landed in #612/#614. This is PR3: the venue’s half of the record. PR4 (the display) is a separate branch.

Why

The record has two writers. The operator attests; the venue falsifies. Until now only the operator wrote, so a wrong attestation stayed wrong forever — executor._run_order stood exactly where venue truth arrived and threw it away (log_exception + re-raise, recorded nowhere), and the next cycle could not tell a read-only credential from a healthy venue.

That is the 2026-08-19 incident’s real cost. The 403 moved no money. The silence cost an afternoon.

The single most important property

TradeScopeDenied latches. Writing it makes rail 20 veto every live ENTRY on that venue until a human types yes at a terminal. So a transient failure classified as a refusal would take a healthy live deployment off the market and require physical presence to restore it.

A missed refusal costs one more refused order. A false one costs an outage. Every classifier here is a narrow allow-list; everything else re-raises untouched. Pinned per adapter against a 5xx, a 503 whose body carries the venue’s own permission wording, a 429, a 401, a timeout, a connection reset, and an unparseable body.

Per venue
venueclassified onwhy
coinbase403 and the SDK’s own Missing required scopes predicate, on preview and placementcoinbase.rest.rest_base.handle_exception hard-codes exactly this branch ahead of every other 4xx. The status alone is not enough: this venue also answers 403 PERMISSION_DENIED for product entitlement — observed live 2026-08-05 against a futures product on a portfolio not onboarded for FCM. Preview is classified too because on this venue preview is a real call under the same scope and the executor previews first; mapping only placement would have shipped a gate that cannot fire on the venue this deployment actually trades.
robinhood403, status aloneThe motivating case: 403 {"detail": "You do not have permission to perform this action."} from a live probe. One observed 403 on this venue and this is it. Narrowing further on unverified prose would fail open the day Robinhood rephrases the sentence — the failure mode with no symptom.
alpacanothing, deliberatelyIts 403 is documented here and pinned by an existing test as insufficient buying power. Mapping it would latch REFUTED over an ordinary cash shortfall. 422 is a bad body, 401 a credential the venue does not recognise, the rest are the network. No permission-refusal shape has ever been observed from this venue, and #233 forbids pre-classifying from documentation. A parametrised test across six statuses holds this refusal in place.

…truncated — full description in #619.

fix(commands): research’s read-only commands stop opening the db read-write (#620)

Closes #610.

Premise checked against origin/main

Confirmed: keel research significance --from deployment, factors and independence all reach their data through _open_repo (keel/commands/_common.py), which calls migrate(conn), and migrate commits unconditionally. keel research pooled-review, in the same file, already connects every profile db mode=ro via its own _connect_ro and never migrates. The inconsistency the issue describes is real and unchanged since filing.

The two decisions the issue left open

1. What a read-only command should do against a database that needs migrating. Refuse, before any data is read. A database stamped below this binary’s SCHEMA_VERSION is a shape this binary was never tested against — reading it quietly would be answering a question with evidence of unknown provenance. The issue’s own instinct (refusal as operator error, not an evidence refusal — #601’s distinction) is what I implemented: _open_repo_ro raises a ClickException naming the stored version, this binary’s version, and the remedy (run a command that writes, e.g. keel migrate, or run a binary that matches its version) rather than silently reading a schema Repository was never built against.

2. How wide to take the seam. The seam (_open_repo_ro in keel/commands/_common.py) plus the three research/ commands, exactly as the issue’s own inclination suggested — nothing in insights, status, pnl, activity was touched. They share the same read-write shape and are explicitly left as a follow-up once this seam has a track record, per the issue’s own “doing all of them is a bigger change than this issue needs” framing.

What changed
  • keel/commands/_common.py — _open_repo_ro(ctx): pooled-review’s own mode=ro URI shape, promoted to a shared seam, with two refusals before any data is read: a missing file (mirrors keel/mcp/tools.py::_open_readonly_repo’s existence check — mode=ro alone already refuses to create the file, but the raw sqlite3.OperationalError names neither the file nor the reason) and a stale schema version (decision 1, above).
  • keel/commands/research.py — research_significance, research_factors, research_independence now call _open_repo_ro instead of _open_repo. pooled-review’s own _connect_ro/read_orders/read_ledger are untouched — they already had the right shape.
  • docs/research-toolkit.md — the “which of these open your database read-write” hazard section is rewritten to state what’s now true (all four research commands that touch orders/trade_outcomes are read-only, the stale-schema refusal message, and the explicit remaining-scope boundary).
  • tests/commands/test_research_commands.py — three new tests (below).
Tests, mutation-verified (each restored after)

…truncated — full description in #620.

test(data): the v2 backfill idempotency pin now actually calls the step twice (#621)

Closes #615.

Premise checked against origin/main

Confirmed exactly as described: test_migration_is_idempotent (tests/data/test_migrations.py) called db.migrate(conn) three times and asserted one broker_subscriptions row. migrate()’s version gate (if current < target) means calls two and three run zero migration steps once the database is stamped at SCHEMA_VERSION — so the assertion held regardless of what _migrate_v2_broker_subscriptions actually does with a row that already exists.

Also found, per the issue’s own “audit the other repeated db.migrate(conn) calls” ask: test_migration_does_not_overwrite_an_existing_row had the identical vacuous shape — a second db.migrate(conn) call that is always a no-op once the database is at SCHEMA_VERSION. Interestingly, this file already has the fix’s own template in house: test_v14_migration_does_not_overwrite_an_existing_attestation (v14’s version of the same test) is paired with test_v14_migration_step_does_not_overwrite_an_existing_attestation_when_rerun, which calls _migrate_v14_venue_trade_scopes directly a second time and says explicitly why that’s the only way to reach the guard. The v2 backfill’s tests never got that companion fix when #233 established the pattern for v14 — this PR is that missing fix, for v2.

What changed

Both test_migration_is_idempotent and test_migration_does_not_overwrite_an_existing_row now call db.migrate(conn) once (the real v1→v14 upgrade path), then call db._migrate_v2_broker_subscriptions(conn) directly a second time, against state the step has already touched — the only way to actually re-enter its early-return guard, since migrate()’s public API will never re-run a step once the stored version matches.

Mutation verification (restored after)

Deleted the guard inside _migrate_v2_broker_subscriptions (the SELECT 1 FROM broker_subscriptions WHERE venue = 'coinbase' LIMIT 1 early return) and re-ran both rewritten tests:

sqlite3.IntegrityError: UNIQUE constraint failed: broker_subscriptions.venue

Both failed (venue is the table’s PRIMARY KEY, so an unguarded second INSERT collides outright rather than silently duplicating or overwriting) — exactly the “mutation someone who disagreed with you would plausibly write” this pin needs to catch. Restored the guard; both tests pass again.

Results
  • uv run pytest -q: 4567 passed, 3 skipped in 152.98s
  • uv run ruff check keel tests packages: All checks passed!
  • uv run mypy: Success: no issues found in 359 source files

Single-file change (tests/data/test_migrations.py), as the issue asked.

feat(setup): the first-run ceremony presents rail 20, and records the off-venue step honestly (#437 D4) (#622)

Closes #437. Closes the “Desktop distribution — keel for non-technical users” milestone.

What was actually left

Most of D4 had already shipped under its own number. keel/commands/setup.py opens with """What a deployment still needs before it can trade -- observed, never assumed (#437, D4).""" and already carried the step taxonomy, the read-only inspect(), the ACTIONS registry, keychain credential capture and the paper-default template; #534/#537/#540 wired the browser Setup page over it; the .app launches keel serve. Three of the issue’s scope items — keychain credentials, migrations on every start, paper by default — were already true and are pinned here rather than built. The full premise check, including the citations that had gone stale, is on the issue.

Three things were genuinely missing, and one of them opened the morning this was written.

Rail 20 was invisible to the ceremony

keel scope attest and rail 20 merged under #233. setup.py’s STEPS had no scope step and READ_TABLES had no venue_trade_scopes, so an operator who completed every step the Setup page listed was still vetoed on their first live entry — by a command the checklist never named. That is the exact failure the checklist exists to prevent, reintroduced by a rail landing faster than the ceremony that presents it.

The step sits between withdrawals_attested and confirm_cycle, which is the runbook’s order: the attestations that release rails come before the supervised cycle that exercises them. The observation reads the row and calls VenueTradeScope.may_place_live_entry() rather than re-deriving the state machine — scope.py is explicit that there must be exactly one place to get that wrong — and the venue resolves the way rail 20 resolves it, so an alpaca deployment is not gated on a coinbase record nothing writes.

It gets no one-click action. keel scope attest --trading is one of the eight rows in keel/capabilities.py: it releases a rail-20 veto and is TTY-gated, so it stays CLI-only, exactly as withdrawals attest --enabled does.

The off-venue acknowledgement, and a contradiction settled

setup.py said two incompatible things. Its module docstring: “keel can show the checklist and record that you say you did it; it cannot verify … never reported as done here — only as acknowledged.” Its ACTIONS comment: “OFF_VENUE steps stay unreachable, because there is nothing there to record that would be true.” No acknowledgement record existed, so the second was in force and the issue’s third acceptance criterion — live requires “the venue-side checklist explicitly acknowledged” — was unreachable.

…truncated — full description in #622.

fix(web): the capability count said eleven in six places, the registry holds eight (#616) (#623)

Why

keel/capabilities.py’s CAPABILITIES inventory holds eight rows today (confirmed against origin/main at the time this branch was cut, with keel scope attest --trading newly added by #233). Six places still said eleven: keel/web/server.py (twice), keel/web/security.py, keel/web/__init__.py, keel/web/static/js/render.js, keel/web/static/js/api.js.

Every one of those sentences makes the same load-bearing safety claim — “not one of the N capability-increasing actions in keel/capabilities.py is reachable from the browser” — and it is the sentence a reviewer checks the disjointness test against. The number has drifted twice already (eleven → seven when the TUI was deleted, seven → eight at #233). keel/cli.py records that history correctly and is untouched. keel/commands/setup.py is being corrected on another branch per the issue and is untouched here. keel/capabilities.py itself is untouched (read and derived from, never edited).

I also fixed four of the same stale “eleven” mentions in tests/web/test_server.py’s own docstrings — leaving them while this PR’s new test asserts the count is eight would have made the test file self-contradictory.

The mechanical fix

test_no_capability_increasing_action_is_reachable_from_the_web_layer already proves disjointness by AST-scanning keel/web/ for any import or call of a gated capability function. It said nothing about whether the prose’s stated count was honest. test_the_disjointness_comments_state_the_actual_capability_count is new, in the same test file (extending rather than duplicating), and:

  • scans every .py/.js source under keel/web/ for the "<word> capability-increasing actions" pattern (tolerant of the line wrap that splits capability- and increasing across a #:/ * comment continuation),
  • converts the stated number word to an int,
  • asserts it equals len(CAPABILITIES) — never a literal written in the test.

A ninth row added to CAPABILITIES without a prose update fails this test until the prose catches up.

Mutation verification (each reverted after)
  1. Added a ninth row to CAPABILITIES, prose untouched:
    AssertionError: __init__.py says 'eight' (8) capability-increasing actions, but
    keel/capabilities.py's CAPABILITIES has 9 rows -- prose is stale
    assert 8 == 9
  2. Reverted security.py’s “eight” back to “eleven”:
    AssertionError: security.py says 'eleven' (11) capability-increasing actions, but
    keel/capabilities.py's CAPABILITIES has 8 rows -- prose is stale
    assert 11 == 8
  3. Deleted the new test function entirely: tests/web/test_server.py dropped from 62 to 61 passing, and test_no_capability_increasing_action_is_reachable_from_the_web_layer still passed on its own — the disjointness pin and the count pin are independent assertions, not one wearing two names.
Test plan

…truncated — full description in #623.

feat(web): five venue-readiness states, derived offline, beside the declarations (#233 PR4) (#624)

Closes the remainder of #233 alongside #619 (PR3). PR1/PR2 landed in #612/#614. This is PR4: the display. Independent of #619 — either can merge first.

Why a second block and not more columns

keel brokers list has always answered one question honestly — what does the adapter declare — and ends on the line that says so:

capability display only -- no key presence is read or implied, and no secret is shown

That line is exactly why the command could never answer the question an operator actually arrives with: can a live entry be placed on this deployment, right now. Both questions are legitimate; they are not the same question, and merging them re-blurs the distinction #233 exists to draw.

So:

  • the declarations block and NO_KEY_INFERENCE_LINE are emitted unchanged and still terminate it;
  • --json is untouched — still the declarations list verbatim. Turning it into a dict, or adding readiness to those rows, would break its consumers and merge the two facts. The machine surface for readiness is /api/venues;
  • the web keeps the same seam: venues and readiness are sibling top-level keys, so _venue_payload’s existing promise that a row there “is not a claim that the venue is configured or reachable” stays true.

Pinned: the line’s exact text, that it terminates the declarations block, and that readiness vocabulary appears in neither that block nor --json.

The states, in the order they are asked

not_installed → not_tradeable → no_credentials → partial_credentials → malformed_credentials → record_unreadable → not_permitted → ready

Three of these were added after review, and each replaced a claim the display could not support — see “Four honesty defects” below.

Each only runs when the more specific one above it did not fire, so an operator gets the most actionable thing true of the venue rather than the most generic. Everything is derived strictly offline — no network call, ever. Nothing here gates anything: readiness reports, rail 20 decides. The trade-scope half calls VenueTradeScope.may_place_live_entry() rather than re-deriving the state machine, the same discipline rail 20 and doctor.trade_scope_findings keep.

malformed is the state the 2026-08-19 incident paid for

ROBINHOOD_API_KEY held the base64 Ed25519 public key derived from ROBINHOOD_PRIVATE_KEY rather than the identifier Robinhood issues. Both present, both well-formed 44-character base64, every request signed correctly, every one 401’d — indistinguishable from a revoked key, a stale clock, or a signing bug.

…truncated — full description in #624.

docs(experiments): all three of #523’s slippage-cap options, measured on one cohort (#625)

Measurement only. Nothing under keel/ is changed — not the cap, not the admission floor, not the model. Adds one pre-registered experiment record, its driver, and its JSONL artifact.

What this is

#523 offers three options for the 50 bp slippage cap and says plainly that none is obviously right. One of them (a $5M admission floor) disqualifies live PAXG. This PR measures all three on the same cohort by the same method so the choice can be made on numbers, and recommends nothing.

#523 is not stale

Every substantive claim re-measured against origin/main (ed206c0) today. Several reproduce to four decimal places — CRO 0.3195 → 0.2428, STX 0.2932 → 0.2276, ATOM/FIL/JASMY exact. The corpus-tail value the cap’s docstring reasons about measures 183.8 bp (TON, $369,944), confirming the “~184 bp” it cites.

One correction: “relative decline is perfectly monotone in the cost multiple” is exact on the issue’s six assets and becomes ρ = 0.9516 with one inversion (STX/ZEC) once the cohort is widened to thirteen. Expected — relative decline depends on P&L shape as well as cost multiple — and it does not weaken the finding.

The result
optiondirectionmagnitude
1 — raise/remove the capconservative−0.3% to −24.0% of net PF
2 — participation scalingflattering+0.9% to +38.6% at the real $50 clip
3 — $5M admission floorneither0.0% on everything it keeps; deletes 15 products + live PAXG

Capping at the corpus tail (183.8 bp) and removing the cap entirely are bit-identical on every product, because nothing cached is thinner than TON.

At the $50 clip this deployment actually fills, square-root-law impact is under 5 bp for every product in the corpus. Inverting the model shows the shipped curve implicitly prices a clip of $26k–$165k — 500× to 12,000× larger than what the box trades. Option 2 is a flattery until the spread it would replace is measured, and keel stores no book data to measure it with.

Nothing in any of the 210 cells reaches net PF 1.0 at n ≥ 100. The best is ZEC 0.8146 under the most flattering arm measured. #523’s own reading holds and strengthens: the cap masks severity, not viability.

Controls
  • Identical entry/exit timestamp sequences across slippage rates of 0, 5, 50, 104.74 and 183.8 bp; identical n_trades across all seven arms on all 30 products. The arms differ in cost alone.
  • BTC/ETH frozen across shipped/tail_cap/uncapped; every product above $5M/day is unaffected by options 1 and 3 by construction.
  • taker_pct: 0.012 separately verified against three real live fills — 1.1838% effective aggregate, ratio 1.0137×. A research claim that it overstates costs by 2× is false; nothing here rests on it.
Reproducing
PYTHONPATH=. .venv/bin/python docs/experiments/2026-08-30-slippage-cap-options.py --workers 8

…truncated — full description in #625.

refactor(rules): the rule contract lives on the rule, not in three tables in agent.py (#447) (#627)

Stage 1 of the three-PR arc from the accepted analysis on #447. The registry stays closed — this PR builds no keel.rules entry point and no plugin loading. It hardens a contract that is currently scattered.

The problem

RULE_REGISTRY was never the only hardcoded kind-table. _DECIMAL_PARAMS and _GRANULARITY_PARAMS (agent.py:131-156) mapped kind → coercion rules, and a third table was not written down at all: a literal if kind == "pullback_continuation" branch inside build_rule_from_params. With param_space() and promotion_class, that put one contract in two files and five places.

Adding a Decimal parameter to a rule meant editing a table in a module the rule’s author has no reason to open, and nothing forced the edit. Forgetting it makes no noise — rules.params round-trips through json.dumps, so the value reaches the constructor as the string "1.5", and the first symptom is a Decimal/float TypeError raised deep inside the rule’s own arithmetic, mid-cycle or mid-backtest.

The change

Rule.decimal_params, Rule.granularity_param and Rule.tuple_params — class attributes, beside param_space() and promotion_class. Class attributes rather than methods because build_rule_from_params must know how to coerce the kwargs before it has an instance to ask.

keel/agent.py keeps the single boundary where conversion is done and gives up deciding what to convert. coerced_param_keys still excludes tuple_params: a tuple param arrives as a JSON list, never a quoted string, and folding it in would make --params '{"ema_periods": "8"}' read as a well-typed tuple declaration.

The pins

tests/strategy/test_rule_contract.py, in three layers:

  1. A rule’s own declaration drives the coercion — proved on a rule this repo does not ship. A test written against TurtleBreakout passes identically whether the coercion reads the class or a dict naming the same string. This test was not missing before the change; it was unreachable.
  2. Each shipped declaration matches its constructor’s type annotations — the one source that cannot be forgotten, so drift is a failing test rather than a production incident.
  3. The live path — every shipped rule’s stored row rebuilds identically through the real lossy hop, values and types. Decimal("2") == 2.0 is true, so an equality-only assertion passes against exactly the regression it guards.
Live-deployment safety

This touches the path that rebuilds live rules from the database every cycle. Layer 3 pins the round-trip end to end for all four shipped kinds. The float-instead-of-Decimal mutation below confirms the type assertion catches what equality misses.

Mutations

Each written as something a reviewer who disagreed would plausibly try, then reverted. Full observed failure text is in the commit body. Summary:

…truncated — full description in #627.

test(strategy): a rule conformance suite, curated rather than shipped (#447 stage 3) (#629)

Stage 3 of the three-PR arc from #447, stacked on #628. The registry stays closed — this ships no keel.rules entry point and no plugin-loading mechanism, and nothing in it needed one.

Why it lives in tests/, not keel/

That placement is the design decision. packages/keel-broker-api/.../conformance/suite.py ships the broker contract from an installable package because third-party adapters exist and need something to prove themselves against. #447 decided the rule registry stays curated: RULE_REGISTRY is a closed, hand-maintained dict of exactly four rules, there is no entry point, and there will not be one. A suite shipped from keel/ would imply the opposite — that some future third party is meant to subclass it and register a rule of their own.

So RuleConformanceTests lives under tests/, imported by four subclasses and nothing else: conformance the four shipped rules are held to, not a contract offered to anyone who is not already one of them.

What it asserts

tests/strategy/rule_conformance.py — a pytest mixin with two hooks, rule() and firing_candles(). Seven assertions × 4 rules = 28 tests.

  • detect() is deterministic across repeated calls — on the same instance, deliberately: PullbackContinuation caches a _RunningState across calls (#352), and a fresh instance per call would never exercise it
  • detect() does not mutate the candle series it is given — the engine loops every rule over one shared read
  • the runtime direction pin holds (direction == "long" plus engine._long_shaped_ok)
  • describe() round-trips through build_rule_from_params, types as well as values, for whatever each subclass configured rather than only the defaults test_rule_contract.py already pins
  • every param_space() spec’s .kwarg names a real constructor parameter
  • promotion_class is a name floor_for_class() recognises — it falls back to the default floor silently for anything else, so a typo promotes against the wrong floor, forever
The anti-tautology guard

Determinism, non-mutation and the direction pin are all checked against the Setup detect() returns — and all pass vacuously if it returns None. A suite whose every rule declined on its fixture would report green while checking nothing.

So the base class takes a second hook, firing_candles(), and _fired_setup() asserts the Setup came back non-None before anything downstream can mean something. That assertion is pinned as its own named test (test_the_firing_fixture_actually_fires) rather than left as an implicit precondition. Each subclass reuses the firing series from the rule’s own test module, already hand-verified gate-by-gate, rather than inventing a fifth set that could drift.

The honest limit, stated in the docstring

detect() is documented as “Pure” on Rule itself, but purity is not an assertable property from inside the same process as the code under test.

…truncated — full description in #629.

feat(strategy): a rule cannot go short, and cannot propose a short wearing a long’s label (#447) (#630)

Reopens the work from #628, which GitHub auto-closed when its base branch was deleted on #627’s merge. Rebased onto main; content unchanged apart from the rebase.

What this closes

Setup.direction: Literal["long"] was a promise to mypy and nothing else. RULE_REGISTRY is a plain dict any caller can write to, so a rule — a first-party one edited carelessly, or a foreign one — could construct Setup(direction="short", ...) and nothing raised.

It has never mattered, and this does not exist because it started to. Four independent layers already stand behind it. What was missing is that #447 asked for the property confirmed by test rather than assumption, and it was assumption.

The design changed under measurement

#447’s analysis asked for stop < entry < target on Setup.__post_init__. It cannot live there. agent._handle_exits rebuilds a Setup for a held position whose stop is open_stop, and exit_policy.next_stop ratchets it strictly upward — max(stop, entry) at break-even, higher on the trail. A winning trailed long has stop > entry as its normal state.

Measured rather than argued: strict → 57 failures; non-strict stop <= entry <= target → still 5, including test_the_live_trail_never_widens_the_stop, which is the live exit path. Dca’s stop=0 / target=entry sentinels are a second, independent reason.

So Setup.__post_init__ enforces direction == "long" only, and the price ordering became engine._long_shaped_ok — at the rule boundary, the one place a Setup is known to be a fresh proposal from rule.detect() rather than a reconstruction of something already held.

It refuses rather than raises. evaluate runs every live rule in one loop; a raise would take the whole cycle down — every well-behaved rule’s entry with it — because one rule proposed nonsense. Skipping emits no Signal, so nothing reaches sizing or execution.

Mutations that changed the work

…truncated — full description in #630.

chore(git): ignore the Playwright MCP’s console-log scratch directory (#631)

An agent driving a browser from the repo root leaves .playwright-mcp/ behind — 74 console logs after one research session on 2026-08-30. Untracked and uncovered by .gitignore, so the next git add -A would have swept them into a commit.

Scratch output of a session, never an artifact. Ignored rather than tidied by hand, because the hand-tidy only works if whoever runs the browser next remembers.

The directory itself is already deleted; this stops it recurring.

fix(backtest): the slippage cap is the corpus tail, not a round number (#523) (#632)

Implements option 1 of #523, as decided by the owner: raise the slippage cap to the value derived from the corpus tail. Options 2 and 3 were measured in #625 (docs/experiments/2026-08-30-slippage-cap-options.md) and rejected — 2 is flattering (+0.9% to +38.6% at the clip this deployment actually fills), 3 would disqualify live PAXG.

The change

SLIPPAGE_CAP_PCT: 0.005 → 0.01838. 183.8bp is not a chosen number — it is the module’s own curve read at the thinnest measured product, SLIPPAGE_FLOOR_PCT * sqrt(anchor / 369,944.11) for TON-USD, quantised to the tenth of a basis point every caller prints at.

The old 50bp was picked so the clamp bound at exactly 100× below the anchor. That put the binding point at $5M/day while ScreenPolicy.min_median_daily_volume admits from $1M/day — so 17 of 30 cached products were capped, 16 of them simultaneously admissible, all charged an identical 50.0bp across a 4.3× spread in liquidity. Inside that band the square-root model degenerated into the flat-fee model #259/#334 shipped to remove. At 183.8bp the clamp binds at ~$370,015/day, beneath the admission floor, so no admissible product can be flattened onto one rate again.

Measured cost: 0.3%–24.0% of net profit factor across the cohort, all conservative, no verdict moved — nothing was net-positive at either rate.

What is deliberately unchanged
  • The admission floor stays at $1M/day, so live PAXG’s status is unchanged by this. Option 3 would have disqualified it at $1.25M median — and at $3.39M on the longer PAXG-USDT view it is still below $5M — but a position the deployment holds must not be dropped as a side effect of a cost-model change. A test now pins the floor so this change cannot alter admissibility.
  • SLIPPAGE_FLOOR_PCT, and the promotion gate (#335).
  • execution.max_entry_spread_pct stays at 0.005 and becomes an independent number. Leaving the live entry gate at 50bp while the cap rises makes it stricter than the model’s worst case rather than equal to it — the fail-closed direction. Every place describing it as “anchored to the cap” now says what it actually is.
What this does not fix

Inverting the square-root impact law at these rates says the shipped curve prices a $26k–$165k clip; the deployment fills $50. The 50bp cap was accidentally holding that overstatement down. This removes one unjustified number and leaves the larger one exposed — that is #626, and this PR does not address it. An honest overstatement, not a correct model.

Acceptance criteria

…truncated — full description in #632.

feat(trade-scope): evidence is bound to the credential that earned it (#633) (#635)

Closes #633.

venue_trade_scopes (#233) records what a venue proved about a credential — but the row was keyed by venue alone. keel credentials forget CDP_API_KEY then set with a different key left it reading coinbase → confirmed, and rail 20 permitted a live entry on the new key using the old key’s evidence.

It failed safe eventually — a scope-less key 403s and TradeScopeDenied latches REFUTED — so the cost was one refused order, not a wrong trade. But one order later than necessary, and in between the record asserted something it could not know.

Why not a hook on credentials set/forget

That gate is one the common path walks around. credentials show resolves from three sources, and forget says so itself — keel does not edit your .env. Editing that file, or exporting a different value, changes the credential with no keel command in the loop. So evidence is bound to the credential and compared at read time; it stops mattering how the credential changed.

What is hashed, and why it is not the secret

Only the identifier. A total preimage break would recover a credential id and nothing that can sign — but that is not the reason it is right:

  • rotating the secret under the same identifier is the same credential with the same scope, and must not withdraw permission
  • a different identifier is a genuinely different credential

Fingerprinting the secret would call an ordinary rotation a credential change, and call a stolen-then-replaced secret under the same key id no change at all — backwards on both counts. Domain- and NUL-separated, so a coincidental string collision across venues cannot read as the same credential.

The production hazard, and why NULL permits

The live deployment’s row was backfilled to confirmed by the v14 migration from the orders log. It has no fingerprint and can never be given one — the migration cannot know which credential placed a 2026-07 order. A naive “mismatch ⇒ not permitted” would veto the next live entry on a healthy deployment, which is exactly what that backfill exists to prevent.

So NULL means “recorded before fingerprinting existed” and is treated as matching. Only a recorded fingerprint that differs may withdraw permission. current_credential_fingerprint never raises and returns None for anything it does not know — callers read that as unknown, never as grounds to refuse. A fingerprinting failure must not stop a deployment.

Verified against a copy of the real live database, not a fixture
before: 12  -> new build: 15        (migrates cleanly)
row: coinbase / confirmed / credential_fingerprint = None

recorded NULL:      matching fp -> True   differing fp -> True   None -> True
recorded "fp-AAA":  "fp-AAA"    -> True   "fp-BBB"     -> False  None -> True

The grandfather clause is real and the discrimination is real.

Verification

…truncated — full description in #635.

docs: the evolution plan — Jesse’s skeleton, keel’s soul (#643)

Saves the evolution plan — discussed and owner-approved across the 2026-08-28→31 delivery sessions — as docs/evolution-plan.md, so it stops living only in session transcripts and every session (plus the site’s engine-docs sync) can find it.

Contents: the Jesse take/refuse table, the three moats, the eight-line constitution, the phased roadmap A–F wired to the already-filed issues (#595, #601, #437, #602, #603, #370/#371, #636, #637, #638, #570–#578; site #117/#120/#135), the crypto-only tier table destined for ADR 0004, the options constitution framing for ADR 0005, the IKA integration architecture, and the standing decisions log (MCP deferral, signing Option D, the repo/board boundary).

Docs-only. No code, no behavior. Companion to #603 and #637.

ci(workflows): three workflows that read no documentation stop running on it (#644) (#645)

Closes #644.

code-quality.yml, security.yml and migrate.yml fired on every docs-only change and can produce no signal from one. Seven docs-only PRs merged on 2026-08-30/31 started all three for nothing.

security.yml is the largest item and the one the sweep first missed — it answers push and pull_request, so a docs-only PR cost two of its runs where the others cost one. Both triggers are filtered.

install-smoke.yml and installer-smoke.yml already key on paths:, so the pattern existed and had simply never been applied to these three.

⚠️ ci.yml is deliberately unchanged

keel’s documentation is under test, and ci.yml runs those tests. Two of them exist because docs drifted and nobody noticed:

  • test_python_floor reads README.md, README.ar.md, docs/desktop-install.md and fails when they disagree with the release’s requires-python — #595/#607, where five places said 3.11 while the wheels required 3.14.
  • test_doc_links pins the documented SHA256SUMS-* filenames against what release.yml actually emits — #605/#618, where the docs promised one file and the workflow shipped three.

A docs-only PR is the only kind those pins are for. Filtering docs out of ci.yml would disable them on precisely the changes they were built to catch.

A comment cannot enforce that, so tests/test_workflow_triggers.py does: the filter is required on the three and forbidden on ci.yml, in both directions. It also refuses a paths: allowlist on ci.yml — the same defect inverted, since an allowlist skips everything not named.

Why this is safe
  1. No branch protection on main (GET /branches/main/protection → 404). The usual footgun — paths-ignore on a required check leaving a PR permanently unmergeable — does not apply.
  2. The weekly crons backstop it. paths-ignore governs only push/pull_request; schedule fires regardless. code-quality runs Mondays 06:00 UTC and security Mondays 04:23 UTC, so even a pathological miss is re-scanned within seven days. migrate.yml has no cron and needs none — its push run is a no-input migration smoke test.
One distinction worth keeping

docs/** sweeps up the experiment drivers and ledgers under docs/experiments/. Correct for these three, which read none of it. Not correct for ci.yml, where docs/experiments/trials-ledger.jsonl is a hash-chained artifact the research tests reason about. Same glob, opposite verdict — recorded so it is not re-litigated.

Mutations, each observed failing and restored
mutationcaught by
the filter added to ci.ymltest_ci_never_skips_a_documentation_change
security.yml filtered on push but not pull_request — the miss this nearly shippedthe per-event loop
the ignore list drifting by one entry (LICENSE dropped)exact-set assertion
ci.yml given a paths: allowlist insteadthe inverted-defect assertion

…truncated — full description in #645.

docs: evolution plan amendments from the external review (#647)

Five surgical amendments to docs/evolution-plan.md from the 2026-08-31 external review, all cross-referenced to where the decisions were recorded: ceremony card v1 fields + idempotency_key+proposal_hash binding (Phase B), SEC XBRL companyfacts + AAOIFI 33/33/5 screening source (Phase C, recorded on #370), Fee Reality benchmark as Phase E’s flagship (#646 + site #136), and the hosted-confirm signer hypothesis — native app or passkey-gated non-extractable WebCrypto with the engine as a dumb relay, settled by validation spike (Phase F). Docs-only. This PR also live-validates #644’s new paths-ignore: Code Quality and Migrate should skip it.

docs: the sw-push claim corrected — the groundwork does not ship (#649)

One-line honesty fix to docs/evolution-plan.md Phase B: the plan claimed Web Push groundwork already ships; sw.js actually carries offline/manifest only — no push handler, no subscription endpoint, no RFC 8291 payload encryption, no VAPID management in the engine. Rewritten as greenfield work, gated behind the remote-exposure security pass (#648). Docs-only.

docs: the Software Architecture Record (SAR) (#650)

Creates docs/architecture.md — the Software Architecture Record. Every claim about the present state was verified against main this session (package layout, the loopback-by-design server checks, confirm.py’s TTY-only path, the kraken honest stub, freshness.py, sw.js’s absent push handler); every claim about the future names its issue (#648, #637, #638, #626, #370…). Companion to docs/evolution-plan.md (strategy) and the ADRs (single decisions). Docs-only — and a live test of #644’s paths-ignore.

docs: the price-drift gate joins the approval envelope (#651)

From the 2026-08-31 SAR review, the one new design worth keeping — with two keelifications: (1) exits are never walled off (entries refuse + re-propose; exits degrade to the typed-phrase path, per _ask_to_place’s documented principle that trapping a position behind a gate is the worse money outcome); (2) the drift tolerance is derived per-product from measured spread (#626), not a flat constant — the #523 lesson. Docs-only.

docs: PDF editions of the architecture record and the evolution plan (#652)

Adds docs/pdf/keel-architecture.pdf (6 pages) and docs/pdf/keel-evolution-plan.pdf (7 pages) — styled, vector, with title pages and metadata — for team review and external sharing. The markdown sources on main remain canonical; the PDFs are regenerable artifacts (converter + render pipeline documented in the delivery session). Docs-only.

fix(agent): a filled paper exit books its outcome and closes its tranche (#639) (#653)

Closes #639.

The caveat first: this is NOT already fixed on main

The issue’s own instruction was to check before writing a line, because the six exits ran under pre-#502 code and book_exit moved in 3d9cc6c. Driven end to end against a throwaway database on cfdea1f — two paper run_once cycles, bar one enters, bar two rips through the target:

cycle 2 orders: [('BUY', 'filled', '100.0500'), ('SELL', 'filled', '114.942500')]
filled SELL with actual_fill: [('filled', '114.942500')]

=== VERDICT ===
trade_outcomes rows: 0
open positions: 1 | all: [(1, 'open', None)]
REPRODUCES

Same script after this change: trade_outcomes rows: 1 | open positions: 0 | all: [(1, 'closed', 176400)].

#502 rewrote a path the paper book never reached.

Why

The agent OPENS a paper tranche — _open_tranche runs in the shared entry loop, paper and live alike — and until now nothing ever closed one. _paper_resolve_bars → PaperTrader.on_candle → _close writes the exit ORDER and forgets the position it belongs to.

_handle_exits cannot cover for it, and this looks like an ordering accident but is not: _held_position reads mode="live" orders only, so in a paper cycle it returns qty 0 and _handle_exits returns [] before it ever looks at a rule. The live exit path is unreachable from paper by construction — and deliberately so, since routing paper exits through executor.execute would put a real broker behind them. So the booking belongs where the tranche was opened.

promotion.py’s pooled sample-size axis (#338) counts trade_outcomes. With nothing writing to it, pooled n is pinned at 0 and no rule can ever be promoted.

What changed
  • keel/agent.py — _book_paper_exit: fetch the exit order, _close_tranches (which is streak.book_exit, so FIFO attribution and the outcome row stay in exactly one place), retire position_rule:<product>. Two things derived, never asserted: is_dca from the tranche’s owning rule (§12.6 exempts DCA from the streak, never from the record), and now_ts from the cycle’s clock, matching the opened_at _open_tranche wrote — a bar timestamp would also set rail 16’s streak_halt_until to a cool-off that expired before it began.
  • keel/commands/doctor.py — one new function + one line in gather_findings. Nothing existing touched (#640/#642 own this file concurrently). ledger.unbooked_exit is stated over the two tables that must agree, not over this bug: an OPEN tranche whose product has a FILLED SELL dated at or after it opened. Modes pooled — an unbooked live exit is strictly worse. WARN, not FAIL: what is lost is evidence.
Pins and mutations

Seven mutations, each observed failing and restored from a scratch copy:

…truncated — full description in #653.

fix(status): a paper position stops crying wolf about a bracket it cannot have (#641) (#655)

Closes #641.

Every open position in a paper deployment rendered NO bracket as a WARN — nine of nine on the current book. has_bracket is bracket_order_id is not None, and paper never places a venue order, so it is False on every paper position by construction. The flag was not reporting a risk; it was reporting the mode, in the vocabulary of a risk, and it could never resolve.

Why it mattered

The cost is desensitisation. NO bracket is a serious warning in live — a real position at a venue without its protective order, the state reconcile_unbracketed_positions and the unbracketed: crash ledger exist to heal (#519, #502). An operator reading nine amber rows a day in paper is trained to ignore the one that counts.

And paper is not unprotected, which is what makes the amber wrong rather than merely noisy: PaperTrader.on_candle resolves the setup’s stop and target against each candle’s range via backtest.py’s shared touch helpers, taking the stop when a bar touches both.

What a paper row says now
caserendering
live, any has_bracketunchanged — bracketed GOOD / NO bracket WARN
paper, initial_stop recordedpaper stop $X — NEUTRAL
paper, no initial_stop (pre-0.12.2 tranche, or a DCA leg)n/a — paper resolves stop/target on candle touch — NEUTRAL

The third row is the #624 lesson: a display must not assert what it cannot know. A tranche with no stop recorded says so rather than inventing one.

Live is untouched, and the guard is ordered so it cannot break by accident
if position.has_bracket or mode != "paper":
    return flag(...)   # the original, unchanged

Every non-paper mode returns before any paper branch is reached. Pinned in both surfaces:

  • test_live_position_with_no_bracket_still_warns
  • test_live_position_with_a_bracket_still_reads_good
  • test_live_position_with_no_bracket_still_renders_no_bracket (terminal)

Both renderers move together — web/payload.py and commands/status.py — so the browser and the terminal cannot disagree about what a missing bracket means.

Verification

4904 passed, 3 skipped · ruff check clean · mypy clean (372 files).

feat(web): the installed console survives the browser closing, and says the truth when it cannot (#634) (#657)

Closes #634. Files #656 for the part that is deliberately not here.

What you hit

Install the console, launch it a second time, land on “Not authorised” with an instruction to go and read a terminal — which a window with no address bar cannot follow. And on 2026-08-31, a plain browser tab autocompleting 127.0.0.1:8765/status out of history hit the same wall, so this was never only about installing.

It was two failures, and conflating them made it look unfixable

(A) The browser closed; keel serve never stopped. The token was valid the whole time. The refusal happened because the cookie carried no Max-Age and died with the browser session — a default nobody chose. A phone evicts an installed app from memory constantly, so this is the everyday case. It is also exactly what the browser-tab report was.

(B) keel serve restarted. The token is new and nothing on the device can know it. This one is a theorem, not a preference: after a restart, no client can present a credential the new run accepts unless the server persisted something across the restart. There is no third way.

All three of the issue’s options aim at (B). (A) is most of the pain and needs nothing persisted anywhere.

The decision: option 3, plus the half of (A) its framing missed

security.session_cookie builds the whole Set-Cookie in one place now, Max-Age included, and the server uses it instead of formatting its own string.

This does not extend the token’s life by one second, and that is the entire argument. The cookie only ever carries ServeConfig.token, which new_session_token mints per process and never writes to disk. When the process exits, every copy of that value — cookie jar, scrollback, a pasted link — stops authenticating anything. So a persistent cookie is a browser holding a value whose power is already bounded by a process it does not control. new_session_token’s “a token that outlives the process it authorised is a credential” stays literally true: the token does not outlive the process; only the browser’s inert copy does.

Thirty days is measured against the useful life of a serve process, not against the value’s sensitivity — the sensitivity ends at process exit whatever that number says.

What it costs, said out loud
  1. Closing the browser used to revoke access. By accident, as a side effect of a missing attribute. It no longer does. The replacement is restarting keel serve, which mints a new token and invalidates every outstanding cookie at once, and serve now prints it: “Stopping keel revokes it: the token is new every run and is never written to disk.”
  2. The token now sits in the browser’s profile on disk rather than only in memory — bounded by the same process lifetime.

Neither makes the --host warning less true. The thing in the way is still a per-run secret that still travels in cleartext.

…truncated — full description in #657.

fix(wrappers,doctor): the deployment stops trading, or stops running, and now says so (#640, #642) (#658)

Closes #640. Closes #642.

One arc: a deployment that degrades silently. #640 is a profile that stopped cycling and nothing said so; #642 is a book that stopped entering and nothing said so. Both are the same defect — keel knows, and does not say — so both are fixed by the same work: something that feeds the rails, and something that reports when they fire.

What shipped

The cycle shape, in all four wrappers — fetch → doctor → cycle → post-cycle doctor. keel-live-run.sh, paperforward-run.sh, paper-hourly-run.sh and paper-equities-run.sh all now fetch before cycling and run doctor on both sides of it. A fetch failure is reported rather than swallowed; any FAIL reaches the notification path. Neither fetch nor doctor can abort the script or change $STATUS. The three paper wrappers gained the notify()/$OSASCRIPT seam they never had.

Exit 4 is no longer indistinguishable from a quiet market. agent.DATA_NOT_READY_EXIT gets its own log line and its own notification, distinct from a generic failure and from a clean cycle with signals=0. The load-bearing “leaves the day unstamped, retries next trigger” contract is unchanged.

doctor can now see a profile that is not running. New profile.scheduled (a plist exists but launchd has no job for it — #640’s exact incident) and profile.cycled (a profile stalled against its own cadence), plus profile.unreadable. Gathered from every profile a deployment declares, so the live book’s daily doctor run reports on the hourly book that has gone dark. Deliberately wired into doctor_cmd rather than gather_findings, because that seam is shared with keel mcp and pinned read-only.

Finding.products — machine-readable per-product identity on data.missing / data.stale / data.gaps, emitted by --json, so a caller can name a cold series without re-parsing prose.

The launchd install step, written down for the first time. Neither runbook contained a single launchctl command. docs/operator-runbook.md gains “Installing and verifying a profile’s launchd job” with the real bootstrap/list/bootout commands and the #640 incident on record; docs/go-live-runbook.md gains a “Schedule it” step.

The per-product acceptance criterion is refused, deliberately

#642 asks that “doctor’s verdict gates PER PRODUCT, not per book… the engine already reasons per product, and the wrapper should not be coarser than the engine.”

On this tree the engine is book-wide, on purpose. keel/agent.py:

# The whole-cycle admission bit: ANY blocked rule, on ANY product, withholds EVERY entry
# this cycle -- see the pre-pass comment above for why this cannot be decided per-rule
# or per-product.
entries_allowed = not blocked_entries

…truncated — full description in #658.

feat(orders): the venue’s book at submit is written down, because spread is the cost (#626) (#660)

Closes #626 — option 1 only. Options 2 (persisted realised slippage) and 3 (a depth snapshot during assets discover --probe-liquidity) are not attempted here, and everything in the issue’s “Not in scope” section (SLIPPAGE_*, ScreenPolicy.min_median_daily_volume) belongs to #523, which was decided and shipped as #632.

The finding that made this cheap

The issue describes option 1 as “capturing best bid/ask at the moment the market order is submitted”, implying new work. keel already fetches it, and has since #350. _run_order previews before every live placement, and the routing-time max-entry-spread gate reads best_bid/best_ask out of that preview — through _preview_book, whose other consumer is #332’s entry-override warning — to decide whether to enter at all. It then threw the quote away.

So this is pure persistence of a value already in hand: no new Broker protocol method, no extra venue call, no added latency, no new failure mode on the order path. The write rides the insert_order that already precedes place_order — the row either goes in with two more columns or the order was never placed, exactly as before.

Why it matters

config.live-sandbox.yaml sets max_per_order_usd: 100 and the three real live fills were $50.00, $50.00 and $61.71. At $50 the square-root law puts market impact under 5bp for every product in the corpus while slippage_for_quote_volume charges 50bp. Essentially the whole modelled cost at this size is spread — and nothing recorded a single quote. That is why #523’s merged measurement (docs/experiments/2026-08-30-slippage-cap-options.md) reports every participation arm as a lower bound on cost and declines to recommend option 2 on that basis.

What is stored, and why

The raw pair, never a derived spread. orders.submit_best_bid / orders.submit_best_ask, TEXT holding str(Decimal(...)) like every other price here. expected_fill/actual_fill are the precedent: two numbers, not one delta. A stored (ask - bid) / mid cannot be re-derived differently later, and half-spread off the mid, the half actually crossed, and relative spread are three different questions off one pair.

Four decisions

…truncated — full description in #660.

feat(orders): the orders table gets a surface — what keel bought, sold, and whether anyone agreed to it (#659) (#661)

Closes #659.

Repository.get_orders() has existed since the beginning and nothing in either front-end called it. Verified before writing a line: the only callers are agent, executor, guards, reconcile, mcp/tools, strategy/paper and doctor — every one a writer’s read, none a surface. grep -rn orders keel/web/ returns three comments and a broker capability enum, and no query. So “what did keel actually buy and sell, and at what price” was answerable only by opening SQLite by hand: status shows what is held right now, activity parses the log file.

This adds one service, one terminal command, one JSON endpoint and one browser view — all over a single report, so the two front-ends cannot disagree about a figure. The #641 arrangement.

What lands
keel/commands/orders.pyOrdersReport, gather_orders, render_orders, keel orders
keel/web/payload.pyorders_payload — places values, derives none
keel/web/api.py/api/orders (GET, read-only), ?scope=, ?limit=
clientordersView in render.js; an eighth nav entry
The load-bearing decisions

The read is unfiltered. gather_orders calls repo.get_orders() with no mode, no product_id, no status, and /api/orders has no ?mode=. Each deployment book holds exactly one mode — keel-live.db all live, keel.db and keel-paperhourly.db all paper — so a mode="live" filter renders empty on two of the three real books and reads as “keel has never traded”. mode is carried per row instead. The pin is on the call (assert repo.calls == [((), {})]), not on a fixture that happens to hold both.

Three counts, because “empty” has two causes. total_count / scoped_count / shown_count, and empty_reason says which: “no orders in this book at all” versus “the book holds N, all of them outside this scope”. An empty table that could mean either is a surface asserting something it has not established.

confirmation leads the row, ahead of the product and the price — pinned as position, not presence, in both renderers. Four words reach that column, not the two the issue described: autonomous, confirm (which means approved, since a declined confirm_fn returns before insert_order), bypass (the pre-2026-07-21 spelling, deliberately unmigrated) and paper. bypass is in AUTONOMOUS_CONFIRMATIONS, not merely in the prose — a view answering “did I approve this” correctly only for rows written after a particular Tuesday would be worse than one that did not answer.

expected_fill and actual_fill sit adjacent with the divergence between them, and the verdict is side-aware: paying more than expected is adverse on a BUY and favourable on a SELL, so a client colouring the minus sign would be wrong on half the rows. Both the number and the judgement are computed in Python; the state is passed to money(), not inferred from the sign.

…truncated — full description in #661.

chore(release): 0.13.0 — schema 12 to 16, and the deployment can finally read itself (#662)

61 commits since v0.12.2 and no release carries any of them. keel update correctly reports “already at the latest release (0.12.2) — nothing to do” while main sits four schema versions ahead. This is the bump that closes that gap; per docs/RELEASING.md the release itself is a human dispatch, and the workflow refuses to change the version so CI never writes to main.

Why a minor, not a patch

Four migrations run in one pass on a v0.12.2 deployment:

v13scale_out’s realized_qty / realized_proceeds / realized_fees (#502)
v14venue_trade_scopes + the backfill from the orders audit log (#233)
v15the credential fingerprint (#633)
v16submit_best_bid / submit_best_ask (#626)

Verified against a copy of the live book, which is stamped at 12: 12 → 16 in one pass, integrity_check → ok, orders ALTERed rather than recreated, and the v14 backfill stamps coinbase as confirmed from real placement history — so rail 20 permits the next live entry rather than vetoing a healthy deployment.

What the release contains beyond the schema
  • The deployment can be read. Orders finally have a surface, in both paper and live (#659). Paper exits book trade_outcomes, so the promotion gate has an input for the first time (#639).
  • It stops failing silently. Wrappers fetch and run doctor, and a FAIL alerts (#642). A stalled profile is visible (#640). Paper stops warning about brackets it cannot have (#641).
  • The console re-authorises without a terminal (#634), and the service worker no longer swallows the token-bearing navigation (#604).
  • Costs got more honest. The slippage cap is derived from the corpus tail rather than chosen (#523), and the venue’s book at submit is recorded so spread stops being the unmeasured term (#626).
  • Rule contract hardening, runtime rails and a rule conformance suite (#447); trade-scope evidence bound to the credential that earned it (#633).
The bump moves the whole workspace

Every workspace pyproject.toml and every exact-version sibling pin, not just the root. Bumping the root alone leaves the workspace disagreeing with itself, and test_workspace_siblings_are_pinned_to_the_exact_version caught exactly that on the first attempt:

workspace versions disagree: {'keel-trader': '0.13.0', 'keel-core': '0.12.2', ...}

uv.lock moves with them — release.yml checks it before anything else and fails the release if it was left behind (#424).

Verification

5104 passed, 3 skipped · ruff check clean · mypy clean (378 files).

After merging

Actions → Release → Run workflow, entering 0.13.0. Then on the deployment, keel update — it backs up all three databases before migrating, and never deletes those backups.

v0.12.2

Voir cette version sur GitHub ↗

Built from 65073da9cf04. Version binds to this hash: keel --version reports keel 0.12.2+65073da9cf04 [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.12.2-py3-none-any.whl
keel versions

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

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

Configure

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

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

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

Desktop app (macOS / Windows)

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

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

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

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

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

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

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

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

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

Other changes

feat(web): the header is a brand, not a word — mark, home, theme choice, mode badge (#598)

Closes #597

What shipped, per the issue’s bullets

The mark and the wordmark, one anchor to /. The favicon’s geometry (icons/keel.svg, transcribed by #594) is inlined into the header with its colours re-pointed at the tokens — in light that reproduces the icon exactly (--accent IS #0c5d52, --bg IS #f8f7f3); in dark the same shape wears the mint accent on the slate instead of carrying light-theme literals onto a dark page. Mark + wordmark sit inside ONE <a href="/" aria-label="keel home"> (the app’s home since #540); it is an anchor, so it is keyboard-focusable and gets the global :focus-visible ring. test_the_inline_mark_is_the_one_the_icons_carry pins the geometry against the icon file so this cannot drift into a second drawing of the boat.

A theme toggle — two states, persisted, OS-following. Details below.

A mode badge, read-only. Details below.

Non-goals held: no mode switching from the browser (nothing added to the write surface — no new route, no POST), no new views, no nav changes beyond the brand block.

The theme state machine (and the flash)

Two states, light and dark; no stored choice → the OS decides; stored choice → fixed, on any OS. The mechanism is one attribute, data-theme on <html>:

  • keel.css now states the dark palette twice — the existing @media (prefers-color-scheme: dark) :root:not([data-theme="light"]) block (OS prefers dark, reader has not pinned light) and a new :root[data-theme="dark"] block (a pinned dark on a light machine — without it the toggle had one working position on a light-OS machine). This is keeltrading.com’s own device (global.css carries the same two blocks). test_a_pinned_dark_choice_paints_dark_on_a_light_machine holds the two blocks exactly equal, and both also set color-scheme so browser widgets follow.
  • Flash avoidance: js/theme.js, the shell’s one CLASSIC script. The standard pattern is an inline head script (that is what the site does), but this shell’s CSP is default-src 'self' with no 'unsafe-inline' — an inline block would be silently refused by the browser. And a type="module" script is deferred, so the page would paint the wrong theme and flip. The CSP-compatible form is a same-origin, parser-blocking classic script in <head>, placed ahead of the stylesheet, so data-theme is on the root before a single rule is applied to a single element. The no-inline-script test was narrowed to permit exactly one such tag (and asserts it precedes the stylesheet — the no-flash property stated as a byte order); theme.js joined the closed _MODULES list, so every module scan covers it.
  • The toggle (main.js) flips from the theme actually showing (asking matchMedia when the attribute is absent — otherwise the button would no-op on a dark-OS machine) and stores under keel-theme, the same key the site’s toggle uses and the one theme.js restores. Storage refused (private mode) still flips this load and starts from the OS next time. A test pins the two files spell the key identically.
  • Both icons ship inline (the site’s own sun and moon, currentColor) — no icon font, no external asset. The same two selectors that paint the page decide which icon shows, so the circle shows the icon of the theme a click switches TO and cannot disagree with the page even before main.js loads.
Where mode/db came from, and why the browser never sets it

/api/config now carries mode, db_path and config_path (payload.config_payload + read_config). Rationale:

  • Why that endpoint: it is the one the client reads at boot on every view (worker registration, docs links, the footer build line), so the badge is present everywhere rather than only where a status report happens to load.
  • mode is the config’s own word for auto_trade.mode (paper / confirm today; the class table also maps live for the day the vocabulary grows it, and an unknown word still renders — the same forward-compat STATE_CLASS gives). It crosses as a bare string, not a Field: a Field would force a state, and confirm beside paper is neither good nor bad. The emphasis table in render.js::MODE_CLASS is the whole visual difference — paper muted, confirm/live carry --accent.
  • The browser NEVER changes it. The route is GET-only (test_no_api_route_answers_a_post still covers the whole table); changing auto_trade.mode is a config-file edit plus a terminal action by design. The payload’s docstring and the badge’s docstring both record the boundary. The tooltip names the db and config in play (<db> · config <config>) — the where-am-I of keel serve, answerable at a glance.
  • Degradation: a config that is missing (first run) or unreadable yields mode: "" and the badge hides (:empty) rather than guessing — the whole shell boots off this endpoint and must not 500 on it.
Test delta
  • tests/web/test_api.py — /api/config names the served deployment (mode paper from the fixture default, both paths); a confirm-mode config reports confirm; a machine with no config degrades to "" with paths intact.
  • tests/web/test_payload.py — the config entry in _every_payload carries the new keys (money-as-strings walk covers them); a focused pin documents bare-strings-not-Fields and the read-only boundary.
  • tests/web/test_client_assets.py — structure pins for the brand anchor / inline mark / toggle / badge; the key-parity scanner extended to /api/config (covering modeBadge and buildLine, which had read that payload since #538 with no parity check — _view_keys now ends a region at the next export or end of file, so the last function in render.js is no longer the one function no payload check can cover); _MODULES + served-type + mount-prefix loops gain theme.js; the no-inline-script pin narrowed to the one classic script, ordered before the stylesheet.
  • tests/web/test_palette_contrast.py — pinned-dark block pinned EQUAL to the media block; and a new global pin that no hex literal exists outside the token blocks, which is how the new surfaces join the contrast suite: they are coloured only with tokens whose pairs the suite already measures (the toggle’s boundary is --control-line, the 3:1 token).
Gates
  • uv run pytest -q — 4437 passed, 3 skipped
  • uv run ruff check keel tests — clean
  • uv run ruff format --check on touched files — clean on every line this PR adds (the flagged hunks in those files are pre-existing baseline drift under ruff 0.15.21, byte-identical on origin/main — CI runs ruff check, not format --check)
  • uv run mypy — clean, 350 files
Hand-checked against a running keel serve

The browserless suite pins bytes, so the interactive half was checked by hand on loopback: the shell serves the brand anchor, badge and toggle with the CSP unchanged (default-src 'self' permits the same-origin classic script, served text/javascript + nosniff); /api/config answered mode: paper with the db/config paths; theme.js was executed out-of-browser against the served bytes for all five storage states (stored light / stored dark / no choice / junk value / storage throws) and set the attribute exactly when a valid choice existed.

chore(release): 0.12.2 (#599)

What this is

The version-bump PR for 0.12.2, following #596’s precedent exactly: one commit, every version and every == pin moves together, uv.lock moves with them. Per docs/RELEASING.md, merging this (not the workflow) is what sets the version — the release workflow then refuses to change it.

Patch, not a minor: since v0.12.1 the only user-facing change is #598. No new interfaces, so the patch is right.

What ships in 0.12.2

Enumerated from git log --oneline v0.12.1..HEAD:

  • #598 — the header is the brand, not a word (#597): the web UI’s header carries the keeltrading.com mark as the home link, a theme toggle (light/dark choice), and a mode badge; the right cluster keeps its margin when the badge degrades, and the mark pin covers rx and stroke-width. Test-first (#597’s 799ed11 pinned the header brand, toggle, and mode badge before the feature landed).
  • Nothing else since v0.12.1 — the log is exactly that one PR’s commits plus its merge.
Files bumped
  • pyproject.toml — version 0.12.1 → 0.12.2, plus the three sibling pins (keel-core, keel-broker-api, keel-broker-coinbase)
  • packages/{keel-core,keel-broker-api,keel-broker-coinbase,keel-broker-alpaca,keel-broker-fake,keel-broker-robinhood,keel-broker-kraken}/pyproject.toml — each version, plus the 11 inter-package == pins
  • uv.lock — regenerated with uv lock; diff is exactly 8 lines (one version per distribution), no unrelated churn; uv lock --check passes

9 files, 25 insertions, 25 deletions. Nothing else: a repo-wide grep for 0.12.1 (excluding uv.lock) finds no live pins, expectations, or even inert prose left behind.

Lock discipline

The trap from #422/#482, verified TDD-style: bumping only the root pyproject.toml’s version first made tests/test_packaging.py fail exactly as designed —

FAILED tests/test_packaging.py::test_every_workspace_package_carries_the_same_version
FAILED tests/test_packaging.py::test_workspace_siblings_are_pinned_to_the_exact_version[keel-trader]
  keel-trader depends on 'keel-core==0.12.1'; it must be 'keel-core==0.12.2'

After completing the set (all 8 pyprojects, 14 pins, uv lock), the same suite is green. uv.lock rides in the same commit because the release workflow checks it is current before anything else (#424).

Gates
  • uv run pytest tests/test_packaging.py -q — 22 passed
  • uv run pytest -q — 4437 passed, 3 skipped
  • uv run ruff check . — all checks passed
  • uv run mypy — no issues in 350 source files

Do not merge unreviewed: the release workflow runs next, after merge, with this same version.

v0.12.1

Voir cette version sur GitHub ↗

Built from f25b6afe16b5. Version binds to this hash: keel --version reports keel 0.12.1+f25b6afe16b5 [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.12.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.

Desktop app (macOS / Windows)

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

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

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

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

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

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

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

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

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

Other changes

docs(analysis-feed): the Analyzer proposes, the gate decides (#579)

What this is

Ibn Khaldun Analyzer’s PRD names keel as its reference subscriber and has already shaped two of its own decisions around keel’s constraints: SSE exists as a first-class transport so keel needs no new dependency, and §3.8.4 binds the platform never to emit an order, a size, a price, or an instruction to trade. This is keel’s side of that contract — a PRD and an SRD, no code.

In one sentence: the feed proposes assets for human attestation; keel’s existing deterministic gate decides; nothing new touches admission, sizing, or order placement.

The finding that decides whether any of this happens

proposer.Candidate requires asset, rationale, and a non-empty sources[]. On the Analyzer’s dual payload, rationale comes from prose.conclusion and is available on the free tier — but asset comes from signal.entities[kind="ticker"] and sources[] from signal.sources. Both machine-readable fields live in the signal block, and signal is the Desk-tier boundary. keel cannot extract a symbol or a citation list from prose without an LLM, which is a dependency it does not have and will not add.

So the integration requires the Desk tier or it does not work at all. That is a purchasing decision, it gates the entire milestone, and it belongs at the top of the PRD rather than being discovered halfway through Phase A.

The case against, stated before the case for

keel’s binding constraint is cost: no shipped rule family is net-positive at the taker fee actually paid, and the viable parameter/fee intersection is empty. A paid subscription does not move that by a basis point. It also cannot pay for itself by improving entries or sizing without becoming a trading signal — which the non-goals forbid outright.

What it buys is evidence and reach, not edge: a wider, cited view of which assets are in play, arriving as proposals a human still has to attest. If after a measured period it has not improved the breadth or quality of attested candidates, it should be cancelled. #578 makes that a dated decision with a published result either way, including a negative one.

And #570 says: do not buy Desk blind. The Analyzer ingests SEC EDGAR, the Federal Reserve, BLS/BEA — macro and equities sources. How much of its output names crypto assets at all is unmeasured. Read a week of free-tier prose first; closing this milestone unbuilt is an acceptable outcome.

The architecture, and the one property it is chosen for
GET /v1/stream/sse ──▶ keel/analysis/feed.py   (urllib, stdlib only, bounded)
                              ▼  AnalysisFrame
                       keel/analysis/adapter.py  (pure: frame → Candidate)
                              ▼  shortlist.json
                       keel assets propose --from …   ← UNCHANGED
                              ▼  screen_fn(repo, product, quote)
                       a report a human reads. Admits nothing.

build_proposal_report injects the gate as screen_fn(repo, product, quote) — three arguments, none of which is the rationale, with a docstring that states the intent. The feed inherits that asymmetry unchanged: it writes the same shortlist file assets propose --from already parses, and that path is untouched. The gate’s input never widens because its prose is better sourced.

The boundary is a file on purpose — inspectable, diffable, keepable.

What we read is a contract, not documentation

The frame also carries direction, horizon, confidence, and dimension_scores — exactly the shape of a trading signal, and exactly what a future reader would be tempted to route into sizing or timing. keel reads none of them. A field keel does not read is a field keel cannot misuse.

shariah_hypothesis is likewise left unset. Nothing in the Analyzer’s output is a qualified source, and a hypothesis sourced from it would be a ruling wearing a citation.

Staleness is keel’s judgement, not the server’s — the counterpart is explicit about that, which is why every frame carries emitted_at. A frame with a missing or unparseable one is dropped, not defaulted to “now”: the failure mode of guessing is treating an old assessment as current.

Config lands under analysis_feed:, never subscription:

That key is rail 14’s per-venue fee-tier attestation, enforced against live trading. An operator reading subscription: lapsed must not have to work out which subscription is lapsed, because one of them stops trading and the other does not.

The pins that matter are the AST scans

Unit tests prove the adapter maps fields correctly today. The scans prove a later change cannot wire an assessment into admission or into a cycle: agent.run_once cannot reach the feed, and nothing under keel/analysis/ may attest, write the allowlist, or place an order. #576 carries all seven, each mutation-verified.

One more, worth naming: the feed appears in no keel/capabilities.py row, because it increases nothing. If a future change gives it the power to attest, promote, arm, spend or place, that change must add the row and the gate — and the pin asserting the absence is what forces the conversation.

Milestone

Phase 14 — Analysis feed (Ibn Khaldun Analyzer)

A1#570 — decide the tier; measure crypto relevance before buying Desk
A2#571 — analysis_feed: config and credential
B1#572 — bounded SSE client on urllib
B2#573 — the frame→Candidate adapter
B3#574 — keel analysis fetch
B4#575 — entitlement visibility
C1#576 — the safety pins
C2#577 — the operator guide
C3#578 — the keep-paying decision

Docs only — no code, no tests, no runtime change.

feat(web): the browser UI wears keeltrading.com’s identity (#594)

Closes #593.

The v0.12 web UI shipped its own palette and its own mark; a user who met keeltrading.com and then opened the app met two products. This PR puts the site’s identity on the app: same paper, same teal, same ship. Palette and mark only — no layout, no components, no JS (no color literal lives in JS; the only hex-adjacent strings there are issue references).

The token mapping

Source of truth: ../keeltrading.com/src/styles/global.css (READ-ONLY sibling repo; CI cannot read it, which is why the copy is pinned by test — see below).

site token (global.css)site value→ app token (keel.css)lightdark
--bg#f8f7f3--bg#f8f7f3 ✅ copy#0f171d (site dark --bg) ✅ copy
--surface#fffefb--card#fffefb ✅ copy#162128 ✅ copy
--ink#1d2833--fg#1d2833 ✅ copy#e9e7e0 ✅ copy
--muted#4b5b69--muted#4b5b69 ✅ copy#a4b4be ✅ copy
--border#d9d4c8--line#d9d4c8 ✅ copy#2b3a44 ✅ copy
--accent#0c5d52--accent#0c5d52 ✅ copy#57c5af ✅ copy
--gold-4 / dark --gold-3#7a5c15 / #d4af37--warn#7a5c15#d4af37
— (no counterpart)--bad#a63e26 (was #7b2915)#e07a6a (kept)
— (no counterpart)--good#1c5c47 (was #1f5f4f)#7cd69f (was #83d3b2)
— (no counterpart)--control-line#84817c (kept)#606f7a (was #706d66)

Site tokens with no app consumer (--surface-2, --accent-strong, --accent-contrast, the notice set, --code-bg, the gold-1/2 remainder) have no app counterpart to map onto — the app has no notice boxes or code blocks in this token set, and inventing consumers is out of scope.

The semantic re-tunes (tokens with no site counterpart)

The site is a brochure, not a console: it has no warn/bad/good. Those keep #532’s semantics — warn is a real difference where nothing is broken, bad is loss, good is gain — and are re-tuned onto the site’s paper, each documented in the css beside its declaration:

  • light --warn: #7a5c15 — the site’s own --gold-4. The gold set is the site’s warm-ink family, and gold-4’s luminance (0.1185) sits closest to the amber the token already wore (#8a5a00, 0.1272): same hue, same job, now the site’s literal. AA 5.81:1 / 6.18:1.
  • dark --warn: #d4af37 — the site’s dark --gold-3. The closer sibling of the light choice (dark --gold-1 #c9a43a) lands at 6.91:1 on --card, a hair under this palette’s AAA working standard; gold-3 is the same ink a step lighter: AAA 8.60:1 / 7.79:1.
  • light --bad: #a63e26 — the old brick lightened within its hue. Against the old near-black ink it cleared every floor; against the site’s lighter #1d2833 it sat at 1.55:1 from body text, under the 1.7:1 floor. Lightening buys distance from --fg (2.38:1) and from --good (luminance delta 0.0334 over the 0.025 floor). AA at 5.87:1 — the one grade this PR lowers, see below.
  • dark --bad: #e07a6a — kept; clears every dark floor against the site’s slate exactly as it cleared them against the old warm black (AA 6.17:1 / 5.58:1, 2.37:1 from --fg, 0.2407 from --good).
  • light --good: #1c5c47 — the old green darkened a step and leaned away from blue: the site’s accent is a teal, and a gain that read as a second brand teal would repeat the link-versus-gain confusion #532 split these tokens to end. AAA 7.33:1 / 7.79:1.
  • dark --good: #7cd69f — the old mint leaned green at the same luminance (0.5488 vs 0.5463), for the same reason: the site’s dark accent (#57c5af) is a mint teal. AAA 10.32:1 / 9.34:1.
  • light --control-line: #84817c — kept: a warm neutral already sitting between the site’s border and its muted ink, clearing the 3:1 boundary minimum at 3.62:1.
  • dark --control-line: #606f7a — the site’s dark border (#2b3a44, 1.54:1 — fine for decorative rules, not for the only cue marking an input) at the lightness the 3:1 minimum demands; the hue moved from warm gray to slate because the page under it did.
Contrast results (the WCAG gate)

Every pair in tests/web/test_palette_contrast.py passes as-is; no threshold was weakened. All measured ratios:

pairlightdarkfloor
fg / bg13.96 AAA14.62 AAAAA
muted / bg · card6.53 · 6.94 AA8.49 · 7.68 AAAAA
accent / bg · card7.25 · 7.70 AAA8.63 · 7.81 AAAAA
warn / bg · card5.81 · 6.18 AA8.60 · 7.79 AAAAA
bad / bg · card5.87 · 6.24 AA6.17 · 5.58 AAAA
good / bg · card7.33 · 7.79 AAA10.32 · 9.34 AAAAA
control-line / bg3.623.493.0 (1.4.11)
line / bg (exempt divider)1.381.54must stay < 3.0
good↔bad luminance delta0.03340.24070.025 / 0.2
min signal-vs-fg1.90 (good)1.42 (good)1.7 / 1.4

One grade-floor entry is lowered, with the reason stated in _GRADE_FLOOR: light --bad AAA→AA. The site’s ink is lighter than the near-black it replaces, which narrows the band a light signal colour can occupy (AAA against the paper, 1.7:1 away from body ink, 0.025 luminance from its opposite) to 0.0207 — too narrow for good and bad both. Holding --bad at AAA meant moving it back toward --good (the exact collision #532 exists to fix) or toward --fg (reading as a neutral row in greyscale); AA at 5.87:1 with record separation from both is the honest trade, recorded per the table’s own protocol (“must not lower without a stated reason and an updated entry”). Every floor value in the suite is untouched.

Pins that legitimately moved were re-measured, not deleted: fg/bg exact pins re-based to 13.96:1 / 14.62:1; --line pins re-based to the site’s border literals (#d9d4c8 / #2b3a44), still asserting the < 3:0 decorative exemption holds; the measured minimums quoted in _MIN_SIGNAL_FG_RATIO / _MIN_GOOD_BAD_LUMINANCE_DELTA comments updated.

New identity pin (tests/web/test_client_assets.py::test_the_palette_wears_keeltrading_coms_identity, written red-first): keel.css’s light --accent and --bg must equal #0c5d52 / #f8f7f3, with the docstring naming keeltrading.com’s src/styles/global.css as the upstream CI cannot read — the identity cannot silently drift.

The mark on every icon

keel.svg’s artwork is replaced by the site’s ship-mark. The structural reality of this repo: icons are generated by scripts/build_icons.py and test_pwa.py requires the committed PNGs to be byte-identical to a fresh render — so the artwork change lives in that script’s geometry (where a diff is readable), not in hand-made binaries.

  • Geometry — transcribed from the site’s public/favicon.svg in its own 24-unit design space: mast, two stays, hull. The hull’s two cubic beziers are sampled at t = 0, .125, …, 1 (chord deviation 0.099% of the tile ≈ 0.5px at 512; four samples per side read as facets at 512 and were doubled).
  • Colours — the site’s --accent teal #0c5d52 with the stroke in the site’s paper #f8f7f3, exactly as the site’s favicon strokes it (not pure white).
  • Round caps/joins — the rasteriser is polygon-only, so they become tangent-edged octagons at every stroke vertex. Butt-capped chord joins left 3px wedges at the hull’s ~18° turns — found by sampling the render against true round-join coverage at 2048px, not by eyeballing.
  • Two deliberate adaptations, both technical: the SVG keeps the site’s rounded tile corners (rx) and true round caps (a favicon sits on an unknown tab background — it is the site’s favicon, redrawn from the same numbers); the rasters stay full-bleed and opaque per #538’s design, and the maskable tile must bleed to the launcher’s crop edge regardless.
  • Tooling — the repo’s own deterministic generator (uv run python -m scripts.build_icons), not an external rasteriser: --check byte-identity is the review mechanism for four binaries. The ship is ~16 small shapes where the k was three, so coverage now accumulates per polygon over its bounding box (same sample grid, same even-odd decision — identical bytes, ~1.5s for all four icons).
  • Manifest / shell — theme_color #0c5d52, background_color #f8f7f3; index.html’s theme-color meta matches (the pwa test holds the two in agreement). The favicon and apple-touch-icon links already point at the regenerated files.
Test delta
  • New: test_the_palette_wears_keeltrading_coms_identity (identity pin, red-first).
  • Re-based: test_fg_on_bg_contrast_has_not_regressed (13.96/14.62), test_decorative_dividers_stay_exempt_and_unchanged (site border literals), _GRADE_FLOOR light bad AA with stated reason, measured-minimum comments.
  • Untouched: every threshold/floor value; the pwa/icon suite passes unchanged (icons regenerate byte-identically from the committed geometry).
Gates

uv run pytest -q → 4421 passed, 3 skipped. uv run ruff check keel tests scripts → clean. uv run mypy → clean (350 files). uv run python -m scripts.build_icons --check → 4 icons match.

chore(release): 0.12.1 (#596)

What this is

The version-bump PR for 0.12.1, following #591’s precedent exactly: one commit, every version and every == pin moves together, uv.lock moves with them. Per docs/RELEASING.md, merging this (not the workflow) is what sets the version — the release workflow then refuses to change it.

Patch, not a minor: since v0.12.0 the only user-facing change is #594, and #592 ships no runtime code. No new interfaces, so the patch is right.

What ships in 0.12.1

Enumerated from git log --oneline v0.12.0..HEAD:

  • #594 — the browser UI wears keeltrading.com’s identity (#593): the UI wears keeltrading.com’s palette; the keeltrading.com mark lands on every icon; the hull closes and the caps land — the mark is the site’s, measured.
  • #592 — the release-workflow attestation fix: the Windows attestation subjects are newline-separated, not space-separated (the space-separated form is one path to the action, and the v0.12.0 dispatch failed at exactly that step). Workflow-only; no runtime code ships.
  • Everything else since v0.12.0 is docs (#579’s analysis-feed decision record).
Files bumped
  • pyproject.toml — version 0.12.0 → 0.12.1, plus the three sibling pins (keel-core, keel-broker-api, keel-broker-coinbase)
  • packages/{keel-core,keel-broker-api,keel-broker-coinbase,keel-broker-alpaca,keel-broker-fake,keel-broker-robinhood,keel-broker-kraken}/pyproject.toml — each version, plus the 11 inter-package == pins
  • uv.lock — regenerated with uv lock; diff is exactly 8 lines (one version per distribution), no unrelated churn; uv lock --check passes

9 files, 25 insertions, 25 deletions. Nothing else: a repo-wide grep for 0.12.0 finds only inert prose — the example ISCC.exe /DKeelVersion=0.12.0 invocation comment in packaging/keel.iss (the real value is passed as a define by the workflow; the #ifndef default is 0.0.0), and the two comments narrating the historical v0.12.0 attestation dispatch failure in tests/test_desktop_packaging.py and .github/workflows/release.yml. No live pins or expectations left behind.

Lock discipline

The trap from #422/#482, verified TDD-style: bumping only the root pyproject.toml’s version first made tests/test_packaging.py fail exactly as designed —

FAILED tests/test_packaging.py::test_every_workspace_package_carries_the_same_version
FAILED tests/test_packaging.py::test_workspace_siblings_are_pinned_to_the_exact_version[keel-trader]
  keel-trader depends on 'keel-core==0.12.0'; it must be 'keel-core==0.12.1'

After completing the set (all 8 pyprojects, 14 pins, uv lock), the same suite is green. uv.lock rides in the same commit because the release workflow checks it is current before anything else (#424).

Gates
  • uv run pytest tests/test_packaging.py -q — 22 passed
  • uv run pytest -q — 4422 passed, 3 skipped
  • uv run ruff check . — all checks passed
  • uv run mypy — no issues in 350 source files

Do not merge unreviewed: the release workflow runs next, after merge, with this same version.

v0.12.0

Voir cette version sur GitHub ↗

Built from cee9e5ad3790. Version binds to this hash: keel --version reports keel 0.12.0+cee9e5ad3790 [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.12.0-py3-none-any.whl
keel versions

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

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

Configure

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

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

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

Desktop app (macOS / Windows)

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

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

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

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

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

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

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

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

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

Other changes

feat(update): D6 — packaged installs update per-release; refusals name the desktop path (#439) (#510)

Closes #439

The decision (Option A — per-release installer)

keel update hard-refuses a bundled layout, twice over (deployment_layout_refusal wants <launch>/.venv/.../site-packages/keel; _uv_install wants uv on PATH) — and #439 asked whether to make it bundle-aware or scope the desktop product to per-release installs.

Decided: A. The installer is the update path. Recorded in docs/decisions/0001-desktop-update-path.md (Context / Options / Decision / Consequences, plus the docs/decisions/ README establishing the pattern). The issue’s own reasoning, which is the record’s reasoning: B buys convenience and costs an update channel that must itself be secured; for a tool that moves real money, a user deliberately downloading a signed installer is the better trust posture; and the os.execv relaunch is poor for frozen GUI binaries.

Standing rules: the uv-venv keel update path is unchanged and remains the answer for terminal deployments; desktop bundles NEVER self-update.

What changed
  • plan_update short-circuits a packaged install to its ONE refusal (packaged_update_refusal). Previously it appended the packaged reason but kept piling on venv vocabulary (“outside the launch folder”, “no distributions installed”) — each true and useless to someone whose update path is a download.
  • packaged_check_lines (pure) — the read-only report for a bundled layout: the same first line as the venv report (current: X latest: Y), then exactly one verdict line in desktop vocabulary: “a newer release exists: vX.Y.Z — a packaged install updates by re-downloading the installer; get it from (see docs/desktop-install.md, ‘How updates arrive’)” / “you are at the latest release — nothing to do” / an honest “cannot compare” on a junk tag. Reuses is_newer_version; no new comparison logic.
  • CLI routing: on a packaged install, keel update and keel update --check are the same read-only report — no plan, no TTY gate (nothing mutates, so there is nothing to confirm) — and a network failure is a calm “could not check … the latest installer is always at ” with exit 0 (an unavailable check is not an error state).
  • Refusal wording: deployment_layout_refusal’s outside-launch branch and the uv-missing error in _uv_install now name the desktop path (docs/desktop-install.md) instead of assuming a venv operator; packaged_update_refusal links the “How updates arrive” section.
  • Docs: desktop-install.md gains “How updates arrive” (updating looks exactly like installing; config/database/credentials are never touched); the operator runbook’s self-update section names its scope (venv deployments) and points desktop users at the installer path.
What deliberately did NOT change

…truncated — full description in #510.

feat(installer): the curl|bash no-warning path, auditable line by line (#479) (#511)

Closes #479

What

curl | bash installer that bootstraps the release wheels — the no-warning path. scripts/install.sh (~215 lines, every step says what and why before it runs), 16 property-pinning tests over its text, a two-OS CI smoke that runs it for real, and pointers from docs/desktop-install.md (which now leads its no-warning section with the installer) and the README quickstart.

Explicitly not downloading an app binary: the OS-warning path stays the .dmg/.zip walkthrough; signing stays #438.

curl -fsSL https://raw.githubusercontent.com/CodeGateSoftware/keel/main/scripts/install.sh | bash
Design decisions
  • Venv location: ~/.keel/.venv, config beside it. One per-user folder you can look inside — keel’s deployment model (keel_core/paths.py). The venv is named .venv deliberately: that is the exact layout keel update’s deployment_layout_refusal recognises, so the updater serves what the installer built. Verified locally: on a fresh install, keel update from ~/.keel answers already at the latest release (0.11.0). No sudo, nothing written outside $HOME.
  • uv if it works, pip otherwise. Discovery probes uv --version, not just command -v — a broken shim (asdf + changed HOME failed exactly this way on my machine) is honestly treated as absent and the pip path (python -m venv + ensurepip bootstrap + pip install --no-input) takes over. Both branches install by exact wheel path with --find-links, mirroring keel update’s _uv_install form — never by name from an index (PyPI’s keel is an unrelated project).
  • Checksums, honestly. The release publishes SHA256SUMS for the desktop artifacts only; there are no published wheel checksums, and the script says so in its output rather than pretending to verify — it records each wheel’s sha256 as computed locally instead, leaving an auditable record of what was installed.
  • Exactly the five production wheels (keel_core, keel_broker_api, keel_broker_coinbase, keel_broker_alpaca, keel_trader), selected by exact <prefix>-<version>- name — the test pins the script’s allowlist equal to PRODUCTION_WHEEL_PREFIXES from keel/commands/update.py, so the two selectors cannot drift. The names of the excluded venue wheels never appear in the script at all.
  • keel versions before success. The verify runs from ~/.keel (so state resolves against the deployment) and its failure fails the script; the success banner is unreachable otherwise. The next-steps output composes with #439 option A: re-run the installer, or keel update for this venv layout; desktop bundles update by re-downloading.
  • Never clobbers the user. An existing config.yaml is kept and said so; keel*.db is acknowledged and untouched; re-running upgrades the venv in place.
Real local run (this Mac, isolated HOME)

…truncated — full description in #511.

D5 delta (#438): Inno Setup installer, gated signing + notarisation, protected signing environment (#512)

What already existed (the reconciliation)

The v0.11 desktop job already freezes --onedir via packaging/keel.spec, wraps macOS into a .dmg (packaging/macos_app.sh, browser-opening launcher, READ ME FIRST inside the image), zips Windows, and attaches per-OS SHA256SUMS + build attestations — all unsigned, by the decision recorded in #438’s comments. This PR is the delta to full #438, exactly the shape the issue’s own “If this is revisited” comment called for: “signing steps drop into the desktop job behind a protected environment. Reversing this is adding steps, not undoing work.”

The delta, in four commits
  1. fix(release) — the wheel-floor comment tells the truth. The verify step claimed Requires-Python: >=3.14.4 while every pyproject.toml says >=3.11. Now derived from the manifest in the test, so it cannot drift. 3.14.4 is the build interpreter pin (.python-version), not a floor the wheel enforces — the comment says so now.

  2. ci(release) — the Windows Inno Setup installer. packaging/keel.iss: PrivilegesRequired=lowest, DefaultDirName={localappdata}\Programs\keel — per-user, no admin prompt. It owns the program directory only; the deployment (config.yaml, keel*.db, .env, logs/) is never written and survives uninstall (no [UninstallDelete]). The zip stays beside the setup.exe (the no-install route docs/desktop-install.md documents); both are attached, checksumed, and attested (one space-separated multi-subject subject-path). The version-decision UX #438 specifies (version from on-disk metadata, confirm same-version, downgrade warning because db.py has no down-migrations) is documented in the .iss header as deliberately not here yet — the issue itself deferred it (“Specified here rather than built”).

…truncated — full description in #512.

feat(broker-api): a bracket is one order kind, because two legs race (#502 stage 1) (#521)

Stage 1 of the #502 design. Port vocabulary only — zero live-path change, and deliberately no live caller.

The executor still builds its raw trigger_bracket_gtc dict against the pre-port CoinbaseClient; moving it is stage 2’s job. Grep confirms zero references to BracketGTC under keel/.

What it adds

BracketGTC joins the OrderSpec sum type. An exit bracket that closes a held position — not an entry-plus-exits parent order, because keel enters with market IOC and protects afterwards, so encoding an entry would represent a shape no keel path produces.

__post_init__ refuses non-positive numerics and a stop that doesn’t sit on the losing side of the target. Equal legs are refused as firmly as inverted ones — an equal-leg “bracket” is a stop and a target racing at the same price, which is not a shape a rule can mean.

No stop_direction field. Derivable from side exactly as StopLimitGTC derives it; a field would let a caller build a SELL bracket that triggers upward — representable nonsense, which is what the sum type exists to prevent.

Names are keel’s, not Coinbase’s. take_profit_price, not limit_price — so a second venue’s translation doesn’t start from Coinbase’s vocabulary, and so it can’t be confused with LimitGTC.limit_price.

The Coinbase translation is byte-identical to what executor._bracket_order_configuration emits today, pinned by a parity test so the two can’t drift while both exist. The test imports both; production code doesn’t.

Deviation from the plan, worth knowing because it recurs

The design assumed “other adapters refuse structurally, add no code.” Half true. Alpaca and Robinhood match OrderSpec exhaustively with assert_never, so widening the union broke both under mypy — each needed an explicit refusal case.

That’s the better outcome (it mirrors the defence-in-depth Robinhood’s translator already applies), but every future OrderSpec kind will hit the same wall, including stage 2’s.

Two venue facts established while here
  • Alpaca does support order_class=bracket — equities-only, and this adapter declares asset_classes={"equity"}. So its absence means “not written yet”, not “impossible”.
  • Robinhood and Kraken are genuine venue limitations — Kraken’s close[...] is an OTO, not an OCO.
Latent gap found, not fixed here

Kraken has no _reject_unsupported — every method raises NotImplementedError rather than UnsupportedOrder, so it would fail test_every_undeclared_order_kind_is_refused if it had a conformance subclass. It doesn’t have one, so nothing fails today. Flagging rather than widening this PR’s scope.

Verification
  • pytest -q — 4,550 passed, 3 skipped
  • ruff check keel tests packages — clean
  • mypy — clean, 353 source files

All three re-run independently after the implementing agent reported, not taken on trust.

Not in this PR

…truncated — full description in #521.

fix(executor): write the crash ledger before cancelling, so a dead roll is loud (#519) (#522)

Closes #519.

The hole

_roll_stop cancelled the resting bracket and then placed the replacement without ever writing an unbracketed: record — place_bracket writes one only when a placement is refused.

So a process dying between the cancel and the place left no resting bracket and no intent. On the next cycle reconcile_unbracketed_positions hit if not intent: continue — the branch that exists for DCA, whose docstring explains that escalating it “would fire a CRITICAL for every DCA tranche on every cycle, which trains the alert to be ignored.”

The position stayed naked with no CRITICAL, indistinguishable from a tranche that carries no stop by design. That’s the worst shape this failure could take: not merely unprotected, but unprotected and invisible.

The fix

Write the record before the venue is touched; clear it only once the replacement rests. Every death in that window now lands in a state the existing sweep already converges — next cycle it finds no bracket, finds these levels, re-places from them.

Deliberately the same unbracketed: key place_bracket uses, not a new roll_intent: one. The sweep, its ledger-sized qty, its escalation and its clear-on-success semantics are written and tested once; a second key would mean a second healer to keep correct. A record left by an aborted roll is harmless — the sweep skips any product whose bracket still rests.

The CRITICAL stays, and the record is retained alongside it. Automatic recovery next cycle is not a reason to downgrade an alert about a position unprotected right now: this deployment cycles once per UTC day, so “next cycle” can be a day away.

Tests

Three, pinning the contract at the crash point rather than around it:

  • test_a_roll_writes_its_crash_ledger_before_touching_the_venue — spies on _cancel_at_exchange (the moment the old bracket stops resting) and asserts the record already exists with the right levels.
  • test_a_successful_roll_clears_its_crash_ledger — left standing, the sweep would re-place a bracket that already rests.
  • test_a_failed_roll_RETAINS_its_crash_ledger_for_the_sweep — the naked case: record survives and the CRITICAL still fires.
Verification
  • pytest -q — 4,553 passed, 3 skipped
  • ruff check keel tests packages — clean
  • mypy — clean, 353 source files
How it was found

The #502 design pass, not an incident. _roll_stop has no automated caller today, so the window is entered only by a manual roll — which is why this has never bitten. #502’s proposed per-cycle stop management would enter it on every ratchet, which is what turned a latent hole into a blocker.

Worth fixing on its own merits regardless of whether that step ever ships.

feat(db): record the stop a tranche was sized against (#520, #502 stage 2) (#527)

Closes #520. First piece of #502 stage 2 — the named prerequisite for the live break-even arm.

The gap

exit_policy.next_stop’s break-even arm computes its threshold from the trade’s original per-unit risk:

entry + be_roll_rr × (entry − initial_stop)

Live state carried entry_fill (the ledger) and open_stop:<product_id> (the current, already-ratcheted stop) — and nothing else. The number the threshold is most sensitive to was simply absent.

Why substituting the current stop is wrong, not merely imprecise

The current stop rises on every ratchet, shrinking (entry − stop). So the threshold creeps toward entry and the arm fires earlier each time, drifting further from the measured policy the longer a trade runs.

Live and sim would then encode two different break-even rules while appearing to share exit_policy’s functions — the exact failure that sharing them was meant to prevent.

What ships
  • v12 migration adding positions.initial_stop, idempotent by the v8/v11 PRAGMA table_info guard (a database stamped at v11 got positions from v4’s DDL, and CREATE TABLE IF NOT EXISTS never adds a column).
  • Written at tranche open by agent._open_tranche from the entry setup’s stop, and never rewritten — that separation from the ratcheting open_stop: is the fix.
  • Repository.open_position takes it; _position_row_to_dict decodes it nullable.
The nullable handling is load-bearing

None means UNKNOWN, and readers must switch the break-even arm off for that tranche rather than guess.

Zero would be a real number — a stop 100% below entry — and a catastrophic one to compute a threshold from. So _position_row_to_dict is careful not to let _text_to_dec invent one, and there’s a test named for it.

The trailing arm is unaffected either way: it needs no original risk, so a tranche without one keeps trailing and simply never break-even-rolls.

No backfill, deliberately. The honest value for every pre-v12 tranche is NULL. Inventing one would fabricate the single input the policy is most sensitive to.

DCA passes None, and that is legitimate rather than degraded — it carries no stop by design, and requiring one would refuse a real tranche.

Tests

Six, in tests/data/test_initial_stop.py:

  • round-trips as a Decimal;
  • unrecorded reads as None, not zero (the load-bearing one);
  • DCA-style open with no stop is accepted, not rejected;
  • the column exists on a fresh migrate;
  • the migration is idempotent and does not backfill;
  • initial_stop is not rewritten when the live stop ratchets.

The three schema-version tripwires were bumped 11 → 12 consciously, which is what they exist to force.

Verification
  • pytest -q — 4,559 passed, 3 skipped
  • ruff check keel tests packages — clean
  • mypy — clean, 354 source files
Not in this PR

…truncated — full description in #527.

docs(prd): what keel can learn from Jesse, which is not its strategies (#530)

PRD from comparing keel’s Rule ABC against the Jesse framework’s Strategy API and its published example strategies.

Milestone: Strategy-API expressiveness (Jesse comparison). Issues: #528, #529.

The conclusion is deliberately narrow

keel is cost-bound, not signal-bound — round-trip friction ~2.5%, no shipped rule family net-positive, turtle_breakout negative on all 24 assets measured. So porting DUAL_THRUST, KDJ, Bollinger, SMACrossover and the rest is the known dead end: a rule negative on 24 assets is not fixed by a 25th signal.

The lesson is the API, not the strategies.

Four of the gaps are already filed — that is the finding

Jesse can express: a conditional entry at a chosen price, a per-bar update_position() hook, pyramiding, partial exits, fill-event hooks, per-strategy state, and a declared hyperparameter space.

Four of those are already keel’s open issues — #333 (conditional entry), #447 (pluggable rules), #502 (stop management, partial exits). An external framework independently re-deriving the same gaps is corroboration of the roadmap, not an addition to it, and the PRD explicitly declines to re-file them.

Two things are genuinely new

#528 — declare each rule’s parameter space. keel already consumes n_trials in research/deflate.py::expected_max_sharpe, reasons per-choice in source comments about whether something “increments the trials budget (§73.12)”, and keeps a ledger with explicit provenance. But the number itself is hand-recorded. Jesse’s hyperparameters() makes it a property of the strategy. This is the one place Jesse’s design is straightforwardly better for a discipline keel cares about more than Jesse does.

Explicit non-goal: no optimiser. Running one against a cost-bound engine manufactures exactly what the deflated-Sharpe machinery exists to detect.

#529 — document the fill-model hazard. Jesse strategies name their entry price and assume the fill (self.buy = qty, entry). keel priced that assumption when #258 corrected the fill model and pullback_continuation’s gross PF fell 0.92 → 0.77 with double the trade count. Any foreign strategy ported naively inherits an optimism this project has already measured — and compounds it with the cost regime.

Method note

jesse.trade/strategies is Cloudflare-protected and could not be read; the analysis is from jesse-ai/example-strategies and the API surface those strategies exercise. Reading the source is the better record anyway: what matters is what the framework lets a strategy say, not what its strategies claim to earn.

What it records in the other direction

…truncated — full description in #530.

docs(design): the web UI rewrite — zero-dependency, view-source, on-device (#531)

Design spec for the UI rewrite, following youperiod.app’s technical philosophy.

docs/superpowers/specs/2026-08-23-web-ui-rewrite-design.md. No code changes.

What it proposes

Delete render.py’s 872 lines of server-side HTML. The local process serves JSON plus static files; the client is plain ES modules with no framework, no bundler, no transpile, no minification, no source maps, and zero third-party JavaScript.

Zero dependencies is achievable rather than aspirational for two reasons: keel’s cryptography lives in Python and the OS keychain, never in the browser — so youperiod’s three dependencies have no analogue here — and money crosses the wire as pre-formatted strings, so the client never computes and never needs a decimal library.

Three findings that shaped it

Installing the app from keeltrading.com cannot work. An origin problem, not a preference. keel’s data is a local SQLite file; an HTTPS page fetching http://127.0.0.1:8765 is exactly the path Chrome’s Private Network Access is tightening and Safari blocks. The exits are a tunnel or a hosted service, both excluded by “no server reach.” So the PWA is served by keel itself — where http://127.0.0.1 is already a secure context, and manifest, service worker and install all work today.

Consequence recorded plainly: iPhone and Android are out of scope, excluded by the constraint rather than deferred by choice.

docs/ is the source, not a duplicate. engine-docs.manifest.json pins CodeGateSoftware/keel@main and fetch-engine-docs.mjs calls itself “the only writer of src/content/engine-docs/”, exiting non-zero if a pinned document disappears. Stripping docs/ from keel would fail the website build.

What ships instead fixes a bug that is already in every release: the wheel carries artifacts = ["keel/templates/*.yaml"] and no docs/, so /glossary renders an empty state in every installed deployment — as help_console.py:138-146 says in its own docstring. The app code drops doc prose and deep-links to keeltrading.com/{lang}/docs/{slug}, which yields en/fr/ar for free.

--good and --bad are the same brightness. Light mode: #1f5f4f L=0.0904, #96322a L=0.0893 — a 1.01:1 ratio. Profit and loss are separated by hue alone (render.py:82 — pure colour classes). WCAG 1.4.1 failure in an application whose central signal is gain-versus-loss; roughly one in twelve men cannot distinguish them, and greyscale or direct sunlight collapse them entirely. Form inputs also have no visible boundary (render.py:96-97, --line at 1.27:1 against the page background), failing 1.4.11 — which lands on D4’s wizard, almost entirely forms.

…truncated — full description in #531.

fix(web): profit and loss were the same brightness, and inputs had no visible border (#543)

Closes #532.

The finding

--good and --bad were photometrically identical in light mode:

good #1f5f4f  relative luminance 0.0904
bad  #96322a  relative luminance 0.0893
                        delta 0.0011  →  1.01:1

Profit and loss were separated by hue alone — render.py:82 carried nothing else. That fails WCAG 1.4.1 Use of Color in an application whose central signal is gain versus loss. Roughly one in twelve men has red-green colour deficiency; greyscale, e-ink and direct sunlight collapse the distinction for everyone else.

--accent was also byte-identical to --good in both themes, so a link and a gain rendered alike.

The rule this change encodes

Move the colour that has room to move away from the background. Toward the background loses contrast; away gains it. When two colours need luminance separation, moving the one with headroom gains separation and contrast instead of trading one for the other.

  • Light background, both colours dark → darken bad.
  • Dark background, both colours light → lighten good.

A first draft did the opposite in both themes and cost two AAA grades to buy separation. That alternative is now recorded in the source comment as rejected, with the reason.

Result — zero grade drops, four tokens improved

Light

tokenbeforeafter
bad7.22 AAA13.92 AAA
accent7.17 AAA7.70 AAA
good7.17 AAA7.17 AAA (unchanged)

Dark

tokenbeforeafter
good8.39 AAA10.39 AAA
accent on card7.83 AAA7.68 AAA
bad6.24 AA6.24 AA (unchanged)

good/bad luminance delta: 0.0011 → 0.0681 light, 0.1234 → 0.2382 dark.

fg, muted and warn are untouched on every surface. Every entry that was AAA before is still AAA.

Form inputs

.field input used background: var(--bg) — the page background — so a --line border at 1.27:1 was the only thing marking the control. Fails WCAG 1.4.11 (3:1 for component boundaries).

Raising --line globally was rejected and the rejection is recorded: --line also draws table rules, the footer border and card edges, which 1.4.11 explicitly exempts as decorative. A new --control-line is scoped to interactive boundaries only — 3.72:1 light, 3.54:1 dark. --line is unchanged, and a test pins that it stays unchanged.

Tests

tests/web/test_palette_contrast.py, 8 tests, pure Python, no new dependency — the WCAG formulas are about twenty lines.

Two properties make it a real guard rather than a restatement:

  • It regexes the tokens out of render._STYLE rather than holding a copy, so a reverted or fat-fingered value fails for real.
  • It pins the grade (AAA/AA), not just the ratio. A ratio floor could not have caught the first draft’s mistake: 4.89:1 and 7.17:1 both clear 4.5:1, and only one is AAA.

…truncated — full description in #543.

feat(web): the serialisation contract — money as strings, values presentation-ready (#544)

Closes #533. Prerequisite for the rest of milestone 22 — seven downstream issues inherit this shape.

Ships keel/web/payload.py and its tests. No endpoints — those are #534.

The three rules

Money crosses the wire as strings, never as JSON numbers. JSON.parse yields IEEE-754 doubles, and keel is Decimal-only precisely because binary floats corrupt money.

Values arrive presentation-ready. The client places them; it never derives them. Every figure a user sees was computed by the Python that holds the rails — which is what makes the invariant checkable rather than merely likely, and why the client needs no decimal library at all.

Semantic state is a field, not an inference.

"pnl": { "value": "-12.34", "display": "▼ −$12.34", "state": "bad" }

A client must never decide “this is bad” from a minus sign — that is arithmetic by another route, and it relocates a judgement into the browser.

The guard is stronger than briefed

I asked for “no monetary field serialises as a number”. What shipped is no JSON number anywhere — a recursive walk over the round-tripped payload, with bool excluded first since bool subclasses int.

That needs no maintained list of money fields, so it cannot rot as fields are added. It also makes the reserved sort field coherent: it would be the single, named exception.

There is a positive control (test_the_number_walker_is_proven_false_capable) so the walker cannot silently stop walking.

Four places it declined to compute

This is the invariant doing its job, and worth reading before review:

  1. Notional on an open position — OpenPositionStatus carries qty and entry_price, and my own spec example shows a notional. It is not emitted. A test pins the absence and names gather_status as where the fix belongs.
  2. Drawdown — drawdown_total_pct is named for a percentage but holds a fraction (guards.py:583 compares it against max_total_dd_pct: 0.20). It crosses unchanged, with no % in the display. This surfaced #542: render.py’s pct() appends % to that same fraction, so the current browser shows a 20% drawdown ceiling as 0.20%. This PR does not inherit that bug and pins that it does not.
  3. A unit on qty — the base asset is not on the report, and deriving it means parsing product_id, whose audited decoder is in guards.py and out of this layer’s reach.
  4. Reusing render._tone_for_rail — it falls through to "good", so "unknown" (which _rail11_status returns deliberately, because “ok” would be a lie) would style as passing.
Thinness pinning

test_console_thinness.py was extended, not forked — the module was already in its scanned set, so Rules 1–5 applied on arrival. Added Rule 6, serialiser-scoped: no .normalize(), no round(), no unscoped float(), no custom JSON encoder.

…truncated — full description in #544.

feat(web): serve static assets, with the headers that make the UI’s reach checkable (#545)

Closes #535.

Adds static file serving plus the security headers the browser client will rely on. No existing security layer is modified — keel/web/security.py has a 0-line diff, and no existing test body was edited.

Headers, and where they go

New /static/* route:

headerscopewhy
Content-Security-Policy: default-src 'self'; connect-src 'self'text/html onlyMakes the UI provably incapable of sending positions, equity or trade history anywhere but the local process. Browser-enforced, verifiable in seconds. Per spec, CSP is invalid and discouraged on non-HTML content types.
X-Content-Type-Options: nosniffevery static responseContent-type sniffing.
Referrer-Policy: no-referrerevery static responseThe app will link out to keeltrading.com, and the session token rides in the URL until the cookie exchange.
Strict-Transport-Securitynever sentThe origin is loopback HTTP by design. A standing comment records why, so nobody re-adds it.

The existing rendered pages keep their original _SECURITY_HEADERS (default-src 'none', X-Frame-Options: DENY) untouched — correct for zero-script pages.

A third CSRF layer

security.py already documents five layers, including a SameSite=Strict HttpOnly cookie and an HMAC-derived CSRF token described in-source as “the layer that does not depend on the browser being current.” This adds one more, because SameSite has had parser bypasses.

Every POST now requires X-Keel-Client: 1, checked before the body is parsed. A custom header forces a CORS preflight a hostile origin cannot satisfy — and the specific attack it closes is the HTML form POST, which is not preflighted and therefore slips past checks that assume all cross-origin requests are.

Sec-Fetch-Site is refused when present and wrong (page JavaScript cannot forge it), and accepted when absent (older browsers).

Directory traversal

resolve_static_asset() resolves and checks containment via .relative_to() — not string matching on "..".

Tested over the real wire and at unit level: /static/../secret.txt, ../../, %2e%2e, double-encoded %252e%252e (must stay inert, not decode twice), a null byte, and /static//etc/passwd — the last being the footgun the module docstring names, since Path("/root") / "/etc/passwd" discards the root entirely.

A measured correction to the packaging story

The issue asserted the wheel omits docs/ because artifacts lists only the YAML template. That was wrong, and I confirmed the correction independently.

…truncated — full description in #545.

chore: raise the Python floor to 3.14, and record why it moved back up (#546)

Support only Python 3.14 and above.

This reverses #283, which was a measured decision, so the change is careful to record what shifted rather than just flip a number. The whole argument lives in tests/test_python_floor.py’s docstring, which is the file that exists to keep this honest.

The measurement from #283 still stands

3.13, 3.12 and 3.11 all pass identically to 3.14. Nothing in this code needs 3.14. assert_never (3.11+) was the one binding feature, and 3.10 still fails collection on it.

So this is a policy floor, not a measured one, and the docstring says so in those words. The two must not be confused by whoever revisits this.

What changed around the measurement
  • Users stopped supplying the interpreter. #283 predates the signed desktop bundle (D5) and scripts/install.sh, both of which bootstrap their own environment. The floor now reaches contributors and packagers rather than everyone who installs — a far smaller group, and one that can install a Python.
  • 3.14 was days old when #283 was decided. Distributions package it now.
What it unblocks — both verified, not assumed

ruff can target py314. It could not before, and ruff.toml recorded exactly why: under py314 against a >=3.11 floor, ruff format rewrote except (A, B, C): into PEP 758’s unparenthesised form — a SyntaxError on 3.11–3.13, which the metadata then invited. Raising the floor removes every interpreter that cannot parse it, so the hazard is gone rather than tolerated. ruff check is clean at py314 with no new UP violations.

The numpy.* mypy override is deleted. numpy’s bundled stubs use PEP 695 type statements that a python_version = "3.11" run cannot parse, so the workspace silenced the transitive crawl — follow_imports = "skip" plus follow_imports_for_stubs — rather than raise the floor every package pins. mypy now passes clean without it, 359 source files.

CI collapses to one leg

Two legs existed to test the two ends of a span that no longer exists. It stays a matrix rather than a scalar so a second supported version is one list entry, and so the required status context stays test either way.

Three literals became derivations

The installer gate (tests/test_install_script.py), the CI leg, and the docs check all now derive the floor from requires-python instead of repeating it. Raising or lowering it again is one edit plus the docstring.

The docs assertion was tightened because it false-passed. "3.14" in readme matched “any Python 3.11+ (the repo develops on 3.14)” — the development pin satisfied the test while the stated floor stayed wrong. It now requires the 3.14+ form. The Arabic mirror is checked for the version alone, because “3.14 فما فوق” is better Arabic than transliterating the English token.

One test deleted with no successor

…truncated — full description in #546.

feat(web): the JSON API over the existing reports, with server-side sort (#547)

Closes #534. Unblocks #536–#539.

Reads only. No new write surface, and keel/web/security.py, tests/commands/test_console_thinness.py and tests/web/test_server.py all have a 0-line diff — verified against the branch, not asserted.

The envelope

Every route answers with the same key set: {as_of, engine, data, sort}. Failures are {as_of, data: null, error: {status, title, detail}}, where status is a string ("404", not 404) — the no-JSON-numbers rule reaches the error document too, so a client never has to switch parsing mode.

routecoverssortable
/api/config——
/api/status/open_positions
/api/setup/setup— (runbook order is the information)
/api/activity/activitycycles
/api/insights/insightsrules
/api/journal/insightsentries
/api/rules/rulesrules
/api/venues/venuesvenues
/api/gates/gates—

/glossary deliberately has no counterpart — it becomes an outbound link in #539. test_the_api_routes_cover_every_html_route_that_reads compares both route tables mechanically, so #540 cannot delete an HTML route the API never replaced.

Sorting

?sort=<column>&dir=asc|desc. An unknown column or direction is a 400 naming the valid ones — silently ignoring is how a dead sort control ships. sort.columns is echoed even when nothing was sorted, so #537 reads the column list off the response rather than hardcoding it.

Ordering lives in payload.order_rows, beside the _plain that wrote the strings, and is Decimal per column — never per row, which would mix Decimal and str in one comparison. Absent values sort last in both directions.

The float test asserts its own premise first. Two ERC-20 quantities one wei apart — legal at an 18-decimal base increment — are distinct as Decimal and equal as float. test_float_and_decimal_orderings_of_the_same_column_genuinely_differ states that disagreement before the endpoint test runs, so the endpoint test cannot be green for the trivial reason that sorting sorts. Mutating order_rows to float produces [0.099, ...002, ...001] where Decimal gives [0.099, ...001, ...002].

Engine stopped is a state, not an error

HTTP 200, engine.state: "warn", data: null, as_of present.

Two deliberate calls recorded in the source: a non-2xx status is what #536/#538 will read as unreachable, and a first-run user with no deployment is not in an outage. And data: null rather than {} — a view handed an empty object renders zeros, which for a trading dashboard is worse than rendering nothing.

/api/config, /api/venues and /api/gates still carry data when stopped: they describe the binary, not a deployment.

The routing layer formats nothing, enforced by AST

…truncated — full description in #547.

feat(web): the client shell — zero dependencies, no build step, served as authored (#549)

Closes #536. Unblocks #537, #538, #539.

The first JavaScript this project ships: 1,239 lines across four modules, zero third-party code, no bundler, no transpile, no minification, no source maps. What the browser runs is byte-for-byte what a reader sees in devtools.

filerolelines
static/js/main.jsentry point, History API router, poll312
static/js/api.jsthe single fetch wrapper231
static/js/render.jsDOM from payloads; no arithmetic587
static/js/format.jsIntl.DateTimeFormat, dates only109
static/css/keel.csspalette + responsive layout318
static/index.htmlshell, landmarks, the one live region103
tests/web/test_client_assets.py44 tests708
Routing

pushState on click, popstate for back/forward, location.pathname the only source of truth.

Deep links and reloads work because resolve_client_route serves the shell for a closed list of seven names, not a wildcard. A wildcard turns a missing .js into a 200 of HTML that the browser then refuses under nosniff — surfacing as a MIME error instead of a 404. Hash routing was rejected: a # in every URL an operator copies.

“keel isn’t running”

api.js normalises four outcomes into one shape, so render.js has no shape branches. HTTP 200 with data: null is never treated as an error — api.py’s docstring warns about exactly that.

A stopped engine keeps the server’s warn; a refusal or unreachable server gets a client-minted bad. Same closed vocabulary, different judgement, which is what state is for. stoppedView is reached only via data === null, so zeros cannot render.

Proving render.js has no arithmetic — and why the first attempt wasn’t enough

A four-state lexer strips comments and strings, then rejects + - * / %, ++, --, Number, parseInt, Math, toFixed, NumberFormat. It is small enough to be obviously correct only because render.js bans template and regex literals — which incidentally makes building an HTML string impossible, so the client has no innerHTML at all.

Mutation testing showed the scan was insufficient. This passed cleanly:

return v.value < 0 ? "bad" : "good"

No operator, no numeric identifier — and exactly the forbidden thing, since state is a field precisely so the client never judges a value. Two further rules close it (render.js never reads Field.value; no relational comparison), and the test carries the mutation that motivated them. Seven mutations verified to fail the suite.

Accessibility

One aria-live/aria-atomic region, on the engine banner — and deliberately not on the data. A 15-second poll wrapped in a live region would re-announce the whole dashboard twice a minute.

…truncated — full description in #549.

feat(web): the remaining six views, an SVG curve nobody has to see, and a stream that says when it stops (#537) (#550)

Closes #537.

The bulk of the client port: /setup, /activity, /insights, /rules, /venues and /gates as client views over #534’s JSON API, plus the equity curve and live updates over EventSource. With this, the browser client reaches parity on information with the rendered pages — which is the precondition #540 (delete render.py’s HTML) and #541 (delete the curses TUI) both wait on.

Parity is on the information, and the divergences are written down

Every view is judged against both the rendered pages and tests/commands/test_tui.py. Where those disagree with each other the divergence goes into the view’s docstring rather than being reproduced — porting a bug forward because the old surface had it is not parity.

Porting found two more instances of #548’s pattern (a lookup that misses, defaulted, nothing raised), both of which the client does not inherit:

  • render.py::_STEP_KIND_NOTE has three entries against StepKind’s four. The missing one is operator_input — the kind of the real credentials step — so the rendered /setup prints an empty note on the step where “what may a wizard do for you” matters most. payload._STEP_KIND_NOTES carries all four.
  • render.py::pct appends % to four values that are fractions (#542). payload.ratio’s docstring names the trap; the client shows the value at the scale the CLI does.
The chart: coordinates are computed in Python

build_equity_curve returns plot coordinates, not just figures. Where a vertical axis starts is what makes a $3 wobble look like a collapse; keel does not let a front-end decide whether a number is bad, and the axis it is drawn against is the same delegation wearing different clothes. So the baseline is always zero, in Python, where a test can read it.

That is checkable rather than asserted: chart.js needs no arithmetic, so the lexer scan that guards render.js now runs over it too — proven by mutation. The curve is role="img" named by its own <figcaption>, with the journal table beside it for the figures.

/setup keeps its gate, and its action set is unchanged

keel.commands.setup.ACTIONS is a 0-line diff, keel/web/security.py is a 0-line diff, and nothing under /api/* answers a POST. The client lists every action keel offers, with its inputs; the button stays on the page that holds the write token, because /api/setup ships no CSRF token on purpose and inventing a way to hand the client one would widen the write surface. A client that hides a button is not a gate, so nothing is hidden — only the button is elsewhere. Where a browser client gets a write token belongs to the milestone (#540), not to this commit.

Verification

…truncated — full description in #550.

feat(web): installable, with a worker that structurally cannot cache a balance (#538) (#551)

Closes #538. Stacked on #550 (feat-537-remaining-views) — that PR was still open when this branched, so review it after #550 lands; I’ll rebase onto main then.

Manifest, icons and a service worker, so keel serve installs as a desktop app and opens to the shell — and its own honest banner — when the engine is not running.

The API rule is enforced by scope, not by care

sw.js ships at the static root, so its registration scope is /static/ and /api/* is outside it. A worker’s fetch handler is never invoked for a request outside its own scope — not skipped, not returned early from: the browser does not consult it. No edit to that file can cache an API response, because no edit to that file can see one. Two further layers back it up: the only cache write in the module is addAll(PRECACHE) (no runtime put, pinned by a test), and an explicit /api/ guard in fetch — dead code today, pinned by a test that says so, because #540 widens the scope to / and it stops being redundant on the day nobody is thinking about it.

This is where the implementation departs from the spec’s file list, which puts sw among the modules in js/. js/sw.js is scoped to /static/js/ and cannot answer a navigation to /static/insights. The alternative — a Service-Worker-Allowed header widening the scope from js/ — was rejected for failing silently: remove the header and the worker still installs, still activates, and simply stops controlling the app. The spec is amended in place rather than quietly diverged from.

The icons are generated, because four binary blobs are not reviewable

scripts/build_icons.py defines the mark once as three stroke coordinates and emits the SVG and all three PNGs from it, with a stdlib rasteriser — point-in-polygon and zlib.compress, no Pillow. --check re-renders and compares bytes, and a test runs it, so a hand-edited PNG fails and a deliberate change has to be made where it is legible in a diff.

The mark is a monogram, and that was decided by looking. Three nautical marks were drawn and rendered first — a hull in section over a keel, a hull in profile with a fin, a bulb keel — and at icon size each read as something else: a funnel, a letter T, and an exclamation mark on a saucer.

One attribute decides whether any of this installs

<link rel="manifest"> is fetched with credentials mode omit by default, and every response this server sends is gated on the session cookie. Without crossorigin="use-credentials" the manifest fetch is a 403, the browser reports no manifest, and the app is not installable — with nothing in the page or the console pointing at the cause.

Verification

…truncated — full description in #551.

feat(web): delete the renderer — the server serves files and JSON, and nothing else (#540) (#553)

Closes #540. Based on main — #551 is merged, so this one is not stacked.

keel/web/render.py is gone: 987 lines of server-side HTML, the seven page handlers that called it, and its inline stylesheet. The client moved from /static/ to / and is the application now. Net −896 lines.

The write surface moved, and that is what made a security layer real

/setup/* was an HTML form. It is POST /api/setup/<key> now, and the action set is a 0-line diff — keel.commands.setup.ACTIONS, still only idempotent, non-destructive, MECHANICAL steps, still asserted disjoint from the eleven capability-increasing actions. A browser can set a deployment up. It still cannot arm a rule, attest an asset or enable autonomy.

What changed is that X-Keel-Client now covers the write path. _api_client_header_ok recorded that widening as “#536’s call to make, once (and only once) the forms it replaces are gone” — because a <form> cannot set a header, and gating it would have refused every legitimate submission with no terminal to fall back to on the desktop bundle. The CSRF token moved into X-Keel-CSRF for the same reason: in a body it proves only that the sender could read it; in a header it also proves the sender could set one.

A reversal, recorded rather than quietly applied

payload.setup_payload used to argue against ever putting the token in a GET response. That argument is quoted in place above the new one. Two of its three concerns were already answered (/api/* is no-store and #538’s worker refuses to cache it; the server binds loopback), and the third — an operator pasting API output — does not survive the observation that settles it: this token authorises nothing without the session cookie, and anyone holding that cookie can mint it themselves. The alternative, a script-readable cookie, would have moved a live token into document.cookie on an origin whose session cookie is deliberately HttpOnly.

Verification

ruff, mypy and the full suite are green (4825 passed, 3 skipped). Then driven in a real Chromium against a first-run deployment with nothing set up, which is the path that matters:

…truncated — full description in #553.

feat: delete the curses TUI and the console layer that only it could open (#541) (#554)

Closes #541. Last issue in the Web UI milestone.

keel tui is gone, and with it the eight console modules reachable from nowhere else. keel/commands/: 24,709 → 13,606 lines. Whole change: +297 / −23,448.

The issue estimated 9,432 lines. The real number is the finding.

The issue reasoned: “tui.py imports from keel.commands.{activity,admission,status} — it is a front-end over the shared report builders, not their owner.” True about the web UI, and half the picture about the module: nine modules imported from tui.py (ScreenLine, _blank, _message_style, CTRL_C_DISCLOSURE, _market_session_style, _human_dt), and the whole console layer sat on them.

Then the decisive fact: there is no keel console command. The console was reachable only from inside the dashboard. Deleting tui.py alone would have left 6,009 lines of source and 8,276 of tests that nothing could open — still type-checked, still pinned, still maintained.

I checked every runtime import of the console layer from outside itself. There was exactly one: keel/mcp/tools.py reading the research corpora. Those four things are now keel/corpus.py, moved rather than reimplemented, so the MCP server reads exactly what the console read. Everything else that looked like a dependency — rules.py, withdrawals.py, update.py, brokers.py, cli.py — was prose in a comment.

Gate 2: enumerated, not assumed

keel capabilities listed eleven actions. Seven [cli], one [tui], three [console] — and each of the four non-CLI rows named the CLI command it mirrored. Every one of those commands is still here (keel autonomy on, keel resume/resume-entries, keel withdrawals attest --enabled, keel rules promote --force). No capability became unreachable.

The registry is seven rows now, all [cli], all behind the typed terminal gate — a cleaner statement than the mix it replaced: every capability increase in this build is a CLI command run by a person at a terminal, and the browser can perform none of them.

One absence looks like a regression and is not. keel rules promote --force skips the whole gauntlet and is not in the registry. It never was: this is an inventory of gate call sites, and that flag has none — rules.py records why (“the CLI’s --force is a flag the operator already typed at a terminal”). The console’s retry flow did run a typed gate before calling with force=True, which is why a force-promote row existed at all; the row described the console’s ceremony, not the CLI’s flag, and it went with the console. Flagging it because the registry now lists no bypass, and that is a fact about the registry’s scope rather than about the bypass.

Dead code that had already become wrong

…truncated — full description in #554.

Three skills run against keel/web: pwa-best-practices, python-pro, improve-codebase-architecture. Two real bugs in last week’s code, one Any leak, and one module split — with the architecture chosen to match youperiod.app, which the design spec already names as its reference.

1. The worker took over a page it did not render

install called skipWaiting() unconditionally and activate called clients.claim(). The argument for that was written into the module: the version-keyed cache makes it safe — new worker, new cache, old one deleted. Sound about caches, silent about the page already on screen. skipWaiting + claim takes over a document rendered by the old build and answers its later requests from the new build’s cache.

keel had a second line of defence that made it hard to notice — a new build means a restarted process, a new session token, and a 403 the banner reports — so the window was narrow and loud rather than wide and quiet. A hazard covered by an unrelated layer is not a hazard dealt with.

The worker now waits; the footer offers “A newer build is ready — reload”; only a click sends SKIP_WAITING, and the reload waits for controllerchange so the takeover lands first. The offer is in the footer, not the engine banner — that banner is the page’s one aria-live region and answers “is keel running”.

Driving that fix found a second bug in it: watchForUpdate returned early with no controller, so on a first visit — the one load where there reliably is none — the updatefound listener was never attached. The controller check belongs on the offer, not the watching.

Also checked from the same list: id was changed /static/ → / in #540, which orphans installs. No release has ever shipped the manifest, so it orphaned none. Now that one has, it’s frozen.

2. keel/web is --strict, and was two errors away

Both errors were one defect: three-argument type() returns plain type, leaking an Any out of a function annotated -> KeelServer, under a # type: ignore[return-value] that did not name the error mypy was reporting (no-any-return) — a suppression that silences nothing while looking like it does. Rewritten as nested subclasses; keel.web.* is now gated strict, which is where it matters most since all of it is reachable from a socket.

test_strictly_typed_packages_ship_a_py_typed_marker caught the consequence, which is the pin working: PEP 561’s marker is a promise to whoever installs a distribution, and keel.web is an internal subpackage with no installer. A marker at keel/py.typed would promise a guarantee for keel.*, still ignore_errors — what that test’s own docstring warns against. It now reads distribution roots, with a mutation guard so the filter can’t quietly empty its own parametrise list.

3. The write boundary is a module

…truncated — full description in #555.

fix(release): the workflow has been invalid since #512 — secrets is not allowed in an if: (#558)

Release has not been dispatchable since 2026-08-23, when #512 landed. Every push since has recorded a failed run; the last twenty are all the same failure.

What was wrong

The four signing steps #512 added were gated on the secrets context in a step-level if::

if: runner.os == 'macOS' && secrets.MACOS_CERT_P12_BASE64 != '' && ...

secrets is not a context GitHub allows in if:. It rejects the file at parse time — Unrecognized named-value: 'secrets' at lines 500, 598, 608 and 644 — so the entire workflow was invalid, not just those steps.

Why it went unnoticed for three days

An unparseable workflow can’t report its own name. GitHub recorded each failure under the file path (.github/workflows/release.yml) rather than name: Release, with zero jobs, on a push event — for a workflow that is workflow_dispatch-only and should never run on push at all. Three signals that each individually look like noise: a run nobody triggered, for a workflow nobody recognises, failing before it did anything.

Then it was masked completely. An unrelated org billing lock began failing every workflow on 2026-08-26 with a three-second “account is locked” error, and this became one red X among many.

The fix, and the one it deliberately isn’t

The usual remedy is a job-level env: block, since env is allowed in if:. Rejected here: that would put every signing certificate and notarisation key into the environment of every step in the job — uv sync, the PyInstaller build, all of it — which is precisely the exposure #512’s own “protected signing environment” exists to prevent.

So the secrets stay scoped to the single step that reads them, and what leaves that step is a boolean. steps.signing.outputs.{macos,windows} is yes/no; nothing derived from a certificate reaches an output (outputs are visible in the run’s API payload); the four conditions read the boolean. shell: bash on that step because it runs on both macOS and Windows runners and the Windows default is pwsh.

Verification

GitHub’s parser is the only authority here, so I let it judge:

  • Pushing this branch at 17:30:51Z produced no release.yml run at all.
  • Every failure on record predates it (17:19, 17:25, 17:28, 17:29 on main).
  • The comparison that makes this meaningful: the earlier push to chore-pwa-arch did produce one (09:22), so a feature-branch push does generate the synthetic failure while the file is invalid. It no longer does.

Locally: parses as YAML, no duplicate keys, desktop job structurally intact with the new signing step id and all four conditions rewritten.

What this means for D5

…truncated — full description in #558.

fix(tests): the signing pins assert the guarantee where #558 moved it, and one they missed (#559)

main is red because I merged #558 without running the suite. This fixes it.

What broke

Three tests in tests/test_desktop_packaging.py assert the signing gates by looking for secrets.X != '' inside each step’s if: — the exact expression #558 had to remove, because GitHub rejects the secrets context in an if: at parse time.

The guarantee is unchanged and still pinned: all five Apple credentials or none, both Windows credentials or none, and each skip notice firing on the exact complement of its sign step. What moved is where that is decided — from four if: conditions into one signing step whose outputs the conditions read — so the assertions move with it.

And a pin for the failure these tests could not see

The old tests asserted that a string appeared in a condition. That says nothing about whether GitHub can parse the file, which is exactly why they stayed green for the three days the workflow was undispatchable. Two new pins close that gap:

  • No secrets in any if:, in any job or step — the thing that made the whole workflow invalid.
  • No signing credential in a job-level env: — the obvious fix for the first one, and the wrong one, because it exposes every certificate to uv sync, the PyInstaller build, and every other step in the job. That is the exposure the signing environment exists to prevent.
Verification

Full suite green (4274 passed, 3 skipped), ruff and mypy clean.

All three pins mutation-checked — a test that cannot fail is not a pin:

mutationresult
restore secrets.WINDOWS_CERT_PASSWORD != '' to an if:2 tests fail
drop APP_STORE_CONNECT_ISSUER_ID from the gate step1 test fails
hoist MACOS_CERT_PASSWORD to job-level env:1 test fails
The process failure, recorded

I validated #558’s YAML, checked its structure, and let GitHub’s own parser confirm the fix — and did not run pytest, because it was “only a workflow file”. This repository pins its workflows with tests; that is the entire reason those three existed. The suite is not optional on a workflow change here, and I’ll treat it that way.

fix(executor): refuse a stop roll that reaches the target, and record why #502 stage 2 is blocked (#560)

Started on #502 stage 2 — “the executor still builds its raw trigger_bracket_gtc dict against the pre-port CoinbaseClient, and moving it is stage 2’s job” — and found it cannot be done as scoped. This ships the guarantee that migration would have bought, and records the blocker.

Why stage 2 is blocked on #524

The port’s signature is place_order(spec: OrderSpec, *, idempotency_key=None).

The executor calls broker.place_order(product_id, side, order_configuration) — three positional arguments and a raw venue dict, which is the pre-port CoinbaseClient’s shape. The executor is not on the port at all: it imports only keel_broker_api.results, and every order kind it places (_order_configuration for market and limit, _bracket_order_configuration for the bracket) is a hand-built Coinbase dict.

So moving the bracket alone has exactly two options, and both are wrong:

  • translate in the executor — needs keel_broker_coinbase imported into keel/execution/, the layering regression the port exists to prevent; or
  • migrate the executor↔broker boundary to specs — that is #524, and it touches every live order path, not the bracket.

I’ve left this in #502 rather than working around it.

The gap that didn’t need the migration

BracketGTC.__post_init__ refuses a stop at or above the take-profit — “a coin flip wearing a protective order’s name”, since the two exits then race at the same level and whichever the venue evaluates first decides whether the position took a profit or a loss.

_roll_stop has never checked it. It guards against widening (new_stop < prior_stop) and against a missing target, then places whatever it was handed.

Reachable rather than theoretical: trail_stop_atr computes price - atr * multiplier, and the live agent cycles once a day, so a gap through the target that reconciliation hasn’t caught up with leaves a recorded target sitting below the newly computed stop.

Refusing is the conservative half — the roll is abandoned and the existing bracket stays in force, so the position keeps the protection it already had. The alternative is cancelling a working bracket to install an inverted one, and if the venue then refuses it, the position is naked until the next sweep.

>=, not >. Equal is the subtler half: two equal prices read as an ordinary pair of numbers, and what they describe is a stop and a target racing at the same price. BracketGTC refuses equal legs as firmly as inverted ones; so does this.

Verification

Full suite green (4276 passed, 3 skipped), ruff and mypy clean.

Both tests mutation-checked:

mutationresult
remove the guard2 tests fail
weaken >= to >the equal-legs test fails

…truncated — full description in #560.

feat(broker-api): a product-catalogue read, the first of #524’s two blockers (#561)

First of the two blockers #524 names. Touches no live behaviour — nothing calls get_instrument yet; this gives the executor something to move onto.

The gap

executor._base_increment_for (#516) needs the finest base_size a venue will accept, and gets it today by calling broker.list_products() and picking through raw dicts for product_id and base_increment. That is the pre-port CoinbaseClient’s shape. The port had no catalogue read at all, which is why the flip isn’t a one-line change.

One product, not the catalogue

get_instrument(product_id) -> Instrument | None. The shape is the caller’s own argument: list_products returns ~900 rows on Coinbase and _base_increment_for caches exactly one per miss, because Repository.set_state commits per call and caching all of them would mean ~900 fsyncs inside the order-placement path. Coinbase’s transport has carried get_product all along.

None is an answer, not a failure — which is why this differs from get_order. An order id was handed to the caller by this venue, so its absence is a real inconsistency. A product id comes from an operator’s allowlist and may simply not be listed here.

Instrument carries product_id and base_increment and nothing else. Quote granularity and minimum sizes are the same class of fact and would sit here naturally, but nothing reads them, and a field no caller reads is a field no test meaningfully checks. __post_init__ refuses a non-positive increment: it is what a caller quantizes against, so a zero crossing the port is a division error or a silent zero size on the exit path.

Five adapters, three different truths
adapterbehaviour
coinbaseget_product, unwrapping either envelope; None for missing/unparseable/zero/negative
robinhoodget_trading_pairs(symbol) — the venue’s min_order_size is this fact, and the port keeps keel’s name for it rather than the venue’s
fakea fixed increment, plus None for a reserved NOT-LISTED id so the conformance suite actually exercises the absent case
alpacaNotImplementedError
krakenthe stub’s standard refusal

Alpaca is the one worth reading twice. Alpaca has /v2/assets/{symbol} carrying exactly this; this adapter’s Transport protocol does not declare it. It raises rather than returning None deliberately — None would tell the executor a symbol is unlisted when the truth is that nobody wrote the read, which on the live path means silently skipping quantization for every equity. Same distinction stage 1 of #502 drew about Alpaca’s bracket support: “not written yet”, not “impossible”.

Conformance

…truncated — full description in #561.

feat(executor): the quote balance comes from the port’s shape, and only available counts (#562)

#524’s second and last blocker. Like the first (#561), this changes no live behaviour — the same numbers reach rail 13 by the same path — and it removes one of the dual-shape probes the issue lists as the payoff.

One question, one answer

_fetch_available_quote called broker.get_accounts() and then probed each row twice — dict key or attribute, currency or .currency, available_balance or .available_balance — because it did not know whether it held the pre-port CoinbaseClient or a port adapter. The port answers list[Balance]; the client answered venue-shaped dicts.

CoinbaseClient.get_balances() now answers in the port’s type as well, computing total as available + hold exactly as keel_broker_coinbase.adapter does — Coinbase exposes no single “total” field, and two implementations of one word must not disagree while both exist. The executor asks one question and the fork is gone. When _build_broker finally resolves through load_broker, this path needs no further change.

get_accounts stays for keel assets holdings (cli.py:557), which reads it through gather_holdings. Moving that is the flip’s business, not this blocker’s.

A gap the migration exposed

Balance carries available and total, which the dict shape never distinguished. I mutation-tested the change and found that swapping .available for .total passed the entire suite — because every fake in the repository sets the two equal.

They are not the same number. available is what the venue will let an order draw on; total includes funds on hold — settling proceeds, collateral behind a resting order. Reading total would let rail 13 pass an order the account cannot fund, which is precisely the failure that rail exists to prevent, and it would do it silently because both are plausible balances.

Now pinned with a balance whose figures differ (100 available of 1000 total), plus the case-insensitive currency match and the no-account-for-this-currency case that None means. The mutation that passed before fails now.

On the fakes

Converting them surfaced the design point worth recording: the old fakes represented “balance unknown” as a row carrying available_balance: None. Balance can’t express that, and it shouldn’t — in the port’s model a currency with no account simply is not in the list. _fetch_available_quote returns None either way, by falling off the loop rather than testing a null field.

Verification

4298 passed / 3 skipped, ruff clean, mypy clean across 347 files. Both sides mutation-checked: a wrong total in the client fails its test, and the .available/.total swap in the executor now fails too.

#524 after this

…truncated — full description in #562.

feat(executor): the increment read goes through the port, and stops fetching 900 products for one (#563)

#561 gave the port get_instrument and wired nothing to it. This wires it, which was the point.

Third additive step on #524, and like the two before it no live behaviour changes — the same increments reach the same quantization through the same cache.

The read the port was built for

_base_increment_for called broker.list_products() — about 900 rows on Coinbase — to use one field of one of them, then picked through raw dicts for product_id and base_increment. That was the pre-port CoinbaseClient’s only catalogue read.

CoinbaseClient.get_instrument() now answers the port’s type too — the same bridge get_balances is — so the executor asks one question whether it holds this client or a real adapter. list_products stays exactly where it belongs: keel assets discover, which genuinely wants the catalogue.

A caching argument that no longer applies

The old docstring defended writing one cache row per miss “even though the response carries every product”, because Repository.set_state commits per call and caching all ~900 would mean ~900 fsyncs inside the order-placement path. The argument was sound and the shape was not. Asking for one product removes the temptation rather than resisting it — there is no longer a catalogue to decline to cache.

The test that pinned it stays, with its reasoning updated rather than deleted: one venue answer must still write one row.

And the adapter that refuses

keel-broker-alpaca raises NotImplementedError from get_instrument, deliberately (#561) — None would tell a caller a symbol is unlisted when the truth is nobody wrote the read.

On this path that must land as unknown, not as a crash. _base_increment_for never raises by contract: unknown means send the quantity unquantized, and an exit that refused to place because a catalogue lookup was unimplemented would be a protective order withheld over a missing convenience. Now pinned with a test.

Verification

4304 passed / 3 skipped, ruff clean, mypy clean across 347 files.

What’s left of #524

Only order placement. Every read the executor makes — balances, increments — now goes through port shapes and needs no further change at the flip. What remains is preview_order/place_order taking OrderSpec instead of raw dicts, and _build_broker resolving through load_broker.

That one is not additive. It rewrites every live order path, and it’s where _bracket_order_configuration, the ConfirmFn dual arm and the capabilities() grandfather clause finally get deleted. Worth doing as its own reviewed change rather than tacked onto this.

feat(executor): every order is an OrderSpec, and the second Coinbase renderer is gone (#524) (#569)

The flip. The executor placed orders by handing hand-built Coinbase dicts to a pre-port client; it now builds OrderSpec values and reads Preview/PlaceResult.

The bytes on the wire are unchanged, and that is verified rather than asserted. Ten order configurations rendered by the new path were compared against the pre-flip renderers loaded out of git — the bracket across four size/price/increment combinations, plus every market leg — and are byte-identical.

One renderer, not two

CoinbaseClient.preview_order/place_order take a spec and render it through keel_broker_coinbase.translate.to_order_configuration — the adapter’s own function. executor._bracket_order_configuration is deleted, and with it the test #502 stage 1 shipped to pin the two byte-identical while both existed. That test’s own words: “The test imports both; production code does not.” There is one now, so there is nothing left to hold in agreement.

_order_configuration becomes _order_spec: BUY is MarketIOCByQuote, SELL is MarketIOCByBase, the bracket is BracketGTC. #516’s quantization is untouched and still runs before the spec is built, including its deliberate BUY/SELL asymmetry.

The trap I nearly walked into

Every OrderSpec carries an initial_status ClassVar, and using it for the order row’s status is the obvious move and wrong. The port’s vocabulary is the venue’s (filled_or_rejected, open); this column is keel’s (filled, pending).

reconcile sweeps for pending. Writing open would leave every resting order invisible to the sweep that exists to observe its fill — a bracket recorded as open is a protective order keel would never look at again.

So _initial_status stays, mapping spec.kind to keel’s words. What went is the dict inspection (next(iter(order_configuration), "")), not the vocabulary. Caught by a test asserting 'filled', which is exactly what that test was for.

Smaller things the types made obvious
  • raw_response stored the entire placement response as JSON so _native_order_id could dig the id back out to cancel with. It stores {"order_id": ...} now, from PlaceResult.broker_order_id. No migration needed — both shapes answer the same data.get("order_id"), so pre-existing rows read unchanged.
  • _preview_book already accepted Preview | dict and the Coinbase adapter already carried the book in detail, so #350’s spread gate and #332’s override warning came through untouched. The executor was written anticipating this flip.
Tests

…truncated — full description in #569.

fix(installer): the floor is the artifact’s (#557); the config is confirm-mode production (#556) (#580)

Two installer bugs, both about the script telling the truth about what it installs. Closes #557, Closes #556.

#557 — the 3.14 floor refused Pythons the wheels it installs support

Root cause. The installer hardcoded the development tree’s Python floor (>= 3.14, from 2d3934f) while the artifact it actually installs — the latest release’s wheels (v0.11.2) — declares requires-python = ">=3.11". On Linux, where most distributions do not package 3.14 yet, the script died at its own step 2 refusing the very Pythons the wheels support (install-smoke run 32714791913, ubuntu-latest red since). The candidate pair python3 python3.14 was likewise welded to that floor: an interpreter installed as a bare python3.X (pyenv, deadsnakes) without shimming python3 was turned away.

Fix — the floor that matters to an installer is the floor of the artifact being installed:

  • Steps reordered: the latest release tag is resolved first (step 2, tag extracted with sed since no Python is known yet), then the Python search (step 3) enforces that release’s floor.
  • After resolving the tag, the script fetches https://raw.githubusercontent.com/CodeGateSoftware/keel/<tag>/pyproject.toml (curl -fsSL) and parses requires-python = ">=X.Y" with a whitespace/3-component-tolerant sed (a non->= specifier parses to nothing → fallback). The run prints which floor it enforces and where the number came from — auditable.
  • If the fetch or parse fails, it falls back to FALLBACK_FLOOR="3.11" — a constant whose comment names what it stands for (the shipped-wheel floor of the releases this installer can pick) and where it must be updated — and prints that the fallback was used.
  • Candidates are derived from the floor: a 3.11 floor tries python3 then 3.14/3.13/3.12/3.11; the check is sys.version_info >= (${FLOOR_MAJOR}, ${FLOOR_MINOR}). No dev-tree 3.14 remains in enforced code.
  • The no-Python failure names the remedies: the deadsnakes PPA (Ubuntu), pyenv install <floor>, and uv python install <floor>.
  • README.md and docs/desktop-install.md qualify the one-liner for Linux with the current shipped floor (>= 3.11) instead of publishing the dev tree’s floor as the installer’s requirement.

Local-run evidence (real run, isolated HOME, this machine — notably an environment where python3 was NOT on PATH and the old fixed pair would have died at step 2):

…truncated — full description in #580.

feat: the live per-cycle stop-management step, off by default (#502 stage 2) (#581)

Partially addresses #502 (the two scope items that remained; see “What did NOT land” for the scale_out remainder).

The audit first: the recorded blocker is GONE

#560’s block note said stage 2 was inseparable from #524 because the executor was not on the port at all (broker.place_order(product_id, side, order_configuration) — three positional args + a raw dict). #569 landed feat(executor): every order is an OrderSpec, and the second Coinbase renderer is gone (#524), and auditing origin/main at that commit found the blocker resolved and, with it, the first two scope items already shipped:

  • Bracket OrderSpec kind — LANDED. keel_broker_api.orders.BracketGTC (bracket_gtc) exists, __post_init__ refuses equal/inverted leg pairs, and keel_broker_coinbase.translate.to_order_configuration renders it to the venue-accepted trigger_bracket_gtc shape (pinned byte-for-byte in tests/broker_coinbase/test_translate.py). The other adapters refuse it out loud: alpaca and robinhood raise UnsupportedOrder at translation naming the capability gap (“not translated yet” vs “the venue has no bracket/OCO order type”), kraken and fake leave it undeclared in capabilities; the conformance suite’s test_the_bracket_declaration_cannot_lie_in_either_direction pins both directions (declared => accepted, undeclared => UnsupportedOrder, never a two-leg synthesis).
  • Executor bracket through the port — LANDED. _bracket_order_configuration is gone; _bracket_spec builds a BracketGTC value and place_bracket/_roll_stop hand it to broker.place_order(spec) through _run_order. No venue dict is built in keel/ anymore (grep trigger_bracket_gtc keel/ is empty), and tests/execution/test_executor.py pins the placed spec is a BracketGTC (lines ~888, ~1617).

So this PR is the part the issue actually opened for: the live wiring, plus two executor enablers it needed.

What lands here

1. roll_stop_to + the roll keeps its tranche link (a16d5bb)

…truncated — full description in #581.

feat(brokers): finish the port flip — the default venue resolves through the registry (#524) (#582)

Audit of #524’s acceptance checkboxes, against origin/main before this PR
#CheckboxStatus at origin/mainEvidence (file:line at origin/main)
1_build_broker resolves the venue through load_broker/discover_brokersUNMET for the default venue — the coinbase branch constructed CoinbaseClient directly; the registry path existed only for non-default nameskeel/commands/_common.py:185-195 (direct construction), :199-201 (registry, other names only)
2Port exposes what the executor needs (catalog/increments, balances), or executor adaptedMET by #561/#562/#563port: get_instrument (packages/keel-broker-api/keel_broker_api/port.py:132), get_balances -> list[Balance] (port.py:130); executor reads them at keel/execution/executor.py:393 and :441
3Live path places every order through OrderSpec valuesMET by #569 — market entries/exits are MarketIOCByQuote/ByBase (executor.py:1271-1315), the bracket is already a BracketGTC OrderSpec (executor.py:1489-1513), placement via broker.place_order(spec) (executor.py:694)—
4Legacy renderer, dual-shape probes and grandfather clause deletedPARTLY MET — renderer deleted in #569; probes and grandfather REMAINEDprobes: ConfirmFn dual type (executor.py:161), _preview_book dict arm (executor.py:945-973), ExecutionResult.preview dual type (executor.py:144), confirm gate dict arm (keel/commands/confirm.py:96-122); place_result.get("success") was already gone (executor.py:704). Grandfather: packages/keel-broker-api/keel_broker_api/capabilities.py:30-40 (“no capabilities() at all”), keel/execution/guards.py:843-845, keel/agent.py:913-915, keel/commands/assets.py:63-74
5Non-Coinbase venue works or refuses explicitly at arm timeMETalpaca wiring _common.py:211-236; un-wired venue refused by name (_common.py:203-209); unknown name fails through the registry’s LookupError listing what is installed (packages/keel-broker-api/keel_broker_api/registry.py:26-28); pinned by tests/test_paper_equities_profile.py (test_build_broker_refuses_a_venue_without_cli_wiring, test_build_broker_unknown_name_surfaces_the_entry_point_list)
What this PR finishes

…truncated — full description in #582.

feat(rules): each rule declares its parameter space, so the trials budget is derived (#528) (#583)

What

Each rule declares its own parameter space, so the size of the explorable space becomes a property of the rule rather than of the operator’s diligence. n_trials — the input to keel’s own overfitting correction (research/deflate.expected_max_sharpe) — stops being a number a human remembers to record (PRD §4.1, the Jesse comparison’s one genuinely new finding).

Design

ParamSpec + Rule.param_space() (keel/strategy/rules/base.py): a frozen spec per dimension — name, type ("int" | "float" | "decimal"), lo, hi, step, and the constructor kwarg the dimension feeds. type names the RANGE’s arithmetic, not the field’s storage: target_rr is a Decimal field whose legitimate values are whole numbers, so it declares "int" — which is exactly what both the int-vs-float suggest dispatch and the cells arithmetic read. cells floors a ragged step (the grid never counts a point beyond the range); bounds preserves the int-pair typing the dispatch relies on; plain() renders JSON-plain. The ABC default is the empty space: a rule with nothing sweepable declares exactly that — a truthful zero, not a missing declaration.

Declared spaces (mirroring what tuning.SEARCH_SPACES hand-maintained since #476 — the ranges “reflect what has actually been considered legitimate”, per the acceptance criteria):

familydeclarationdeclared_cells
turtle_breakoutentry_lookback 20–60/5, exit_lookback 10–30/5, adx_threshold 20–35/5, atr_stop_mult 1.5–3.0/0.5, target_rr 3–8/14320
rsi_meanrevoversold 15–30/5, overbought 70–85/5, atr_mult 1.0–2.5/0.5, fixed_rr 1–3/1, rsi_period 10–21/12304
pullback_continuationema_fast 5–12, ema_mid 15–30, ema_slow 40–70 (each naming the one ema_periods kwarg it feeds), buffer_ticks 0.01–0.05/0.0119840
dca— (accumulation, not a risk-defined trade; #476 excluded it for exactly that)0

A candor note on the steps: they are the resolutions the older manual grids (the 2026-08-11 turtle / 2026-08-12 rsi sweeps) moved in and the convention the cells count is priced at — not what the #476 TPE study moved in. suggest_int/suggest_float carry no step, so that study drew step-1 ints and continuous floats, off these grids; off-grid draws are invisible to a min/max box, so every cells count (declared_cells, explored_cells) is a grid convention, never a set of visited points, and the multiple-comparison budget is n_trials, never the grid size.

…truncated — full description in #583.

docs(research): the fill-model hazard in externally-sourced strategies (#529) (#584)

Closes #529. Milestone: Strategy-API expressiveness. Docs only — no code paths touched; suite green (uv run pytest -q: 4340 passed, 3 skipped).

Where the doc landed, and why

docs/experiments/2026-08-27-external-strategy-evaluation-hazard.md (plus a newest-first index entry in docs/experiments/README.md).

Chosen over docs/research/ because the cost-restatement corpus the issue names (“alongside the cost-restatement documents”) lives in docs/experiments/ — the 08-12 fee curve and the 08-13 restatement are the two records every number in this hazard cites — and because docs/experiments/ is where the scout skill already directs a proposer to read before writing any output. That is the meet-point the acceptance bar describes. The doc states up front that it reports no new measurement; it is the reading-side analogue of the invariants the 08-13 record demanded.

Section shape
  1. The hazard — the Jesse self.buy = qty, entry idiom vs keel’s #258 next-bar-open market fill; the worked example with both numbers; the compounding with ~2.5% round-trip friction (“optimistic on both axes at once”).
    • 1.1 A caveat the worked example must not lose — carried verbatim in spirit from the 08-13 record §3.1: 0.92 → 0.77 is not evidence that resting entries add edge (both sides lose gross; the filter separated bad from worse). It is evidence for #260’s landmine. This prevents the doc from being read as an argument for #333.
  2. Why a document and not code — nothing to enforce; the engines are already correct; the hazard is in how a human reads someone else’s backtest.
  3. The four questions — each with what to establish / why it matters here (measured keel anchors) / what a wrong answer costs: fill model, cost regime, sample size & window, capability (#333/#502/#447/#260).
  4. The checklist — a copy-paste markdown form: the four questions as yes/no/unknown prompts (“unknown is a real answer”) plus a proceed/do-not-port verdict, answered before porting effort.
  5. Where this is enforced for machine-generated proposals — the scout skill’s cost_regime/sample_size/evaluation_window citation fields are the same discipline; the two are kept in step.
  6. Provenance of every number — a table mapping each figure to its record of origin.
Cross-reference points (the acceptance criterion)

Three places, each one where a person proposing a candidate would actually see it:

…truncated — full description in #584.

feat(research): the 2026-09-30 pooled-review report driver (#427) (#585)

What this is

The REMAINDER of #427 is running the 2026-09-30 pooled review with the corrected reporting (tracked in #353) and the owner’s floor decision. This PR lands the tooling so the review event is one command and its report cannot be written imprecisely. The correction scope itself landed long ago (PR #503: roadmap PRD, glossary, cross-verification §4.1, discussion #359’s corrected comment).

Partially addresses #427. What remains open, exactly: (1) the review event itself — 2026-09-30, #353’s to run (docs/experiments/2026-09-30-pooled-review.py with default args is that run); (2) the owner’s floor decision (below). This PR does not close #427.

Design

Two pieces, matching the significance/optuna shape (pure seams in keel/research/ pinned by tests, thin IO driver in docs/experiments/):

keel/research/pooled_review.py (pinned by tests/research/test_pooled_review.py, 25 tests):

  • Extraction mapping — orders/trade_outcomes rows → the OutcomeRow sequence significance() reads. A trade_outcomes row is authoritative and its pnl_net is carried verbatim (recomputing would drop the entry fee the row folds into pnl but does not store). Where the ledger is empty, round trips are reconstructed from filled orders: BUY matched to SELL FIFO by order id within (profile, product, rule, mode, qty), priced with streak.record_closed_trade’s own formula (exit - entry) * qty - both legs' fees. The two records dedup on (profile, product, qty, exit fill). Win/loss by the sign of fee-honest net pnl; pnl exactly 0 is a scratch (counts toward nothing); open positions and unfilled/rejected orders are excluded and counted; timestamp inversions are flagged, not trusted and not hidden.
  • power_sentence(n) — generated at the pool’s ACTUAL n through throughput.n_eff/detectable_edge: “at this n_eff (N effective of M pooled), this review can only see an edge of X points or larger (80% power, one-sided 5%)”. At n=100 it says “20.0 points” (the corrected #359 comment’s own display); at n=12 it says “57.6 points”. Pinned to the exact string.
  • render_report — title, event date, method citation, composition table, raw n never without its n_eff, the sentence constructed in, and the not-a-pass/fail-gate statement built in. No verdict vocabulary on the edge anywhere: the significance machinery measures (win rate, break-even, edge, CI) but none of its distinguishable/insufficient_n verdicts render — the only verdict-shaped statement is about POWER.
  • Refusal — zero pooled trades → “nothing to review”, exit 2, refusal JSONL row only, never a degenerate report.

…truncated — full description in #585.

feat(brokers): alpaca refuses margin accounts, cash-only by posture (#372) (#586)

Closes #372 · Phase 12 — Stocks via Alpaca (PRD §5 “Cash-account discipline”, §8)

What this does

Encodes and enforces the cash-account posture where the venue can tell us, and documents what only the operator can know — with the split stated explicitly, never blended.

The API fact the enforcement rests on (the honest answer to “does Alpaca surface account type?”): there is no account_type field. /v2/account’s multiplier is the venue’s account margin classification — 1 a “standard limited margin account with 1x BP” where “buying_power = cash” (the cash-equivalent posture: Alpaca opens every account as margin and offers no true cash designation — multiplier 1 is as cash as the venue gets: shorts refused, buying power cannot exceed cash), 2 reg T margin, 4 PDT day-trading margin. So the posture is fully readable — no API gap to work around — and AlpacaAdapter.verify_cash_account() reads it once per broker build: multiplier 1 passes; anything else raises CashAccountRequired naming riba, the PDT threshold the posture sidesteps, and the fix; an absent/unparseable/unreachable classification raises the same fail-closed refusal (rails 12/13’s silence-is-not-consent rule), cause chained. The seam is _build_broker — every command that constructs a broker (agent cycle, fetch, monitor, order paths) inherits the refusal before any engine path sees a broker; one extra GET /v2/account per build sits far inside FR-11’s rate budget at the daily cadence.

A wrong claim this corrected: the runbook said “the paper account is [a cash account] by default”. It is not — Alpaca’s default for any account with ≥ $2,000 equity is reg T margin (multiplier 2), and a fresh paper account carries $100k. The enforcement refuses that default on the first cycle until the operator sets the account’s max margin multiplier to 1; the runbook and config.paper-equities.yaml now say so.

Enforce vs document — where each constraint lives and why

…truncated — full description in #586.

docs(decisions): ADR 0002 — SQLite persistence, and the triggers that would reopen it (#526) (#587)

Closes #526.

#526 is explicitly not a proposal to change persistence. It asks that the reasoning (one writer; the audit trail IS the schema; the db is a file — copyable, diffable, attachable, backed up before migration) and the four concrete triggers be recorded durably, ADR-style, so the question stops being re-litigated — with the standing rule that a fired trigger is answered with a MEASUREMENT, never “Postgres feels more serious”.

Where each piece landed

docs/decisions/0002-sqlite-persistence.md (new) — the ADR, in the 0001 voice (Context / Options / Decision / Consequences, dated, citing #526 and the SE walkthrough §9.6 that raised it):

  • Context — the shape with the issue’s numbers: three profiles (live, paper-daily, paper-hourly), each its OWN db file so still one writer per file; live trades once per UTC day, hourly cycles hourly; keel.db ~129MB mostly candles; keel migrate runs per database; backup-before-migration <db>.bak-before-<version>-<stamp>. Cites db.py::connect’s WAL rationale (with its #437 measurements) as the precedent, and bounds what WAL buys: concurrent readers with one writer — not two writers.
  • Decision — SQLite stays; the synchronous one-writer design is load-bearing, not a limitation. The SE walkthrough’s “why not async?” answer (§10, quoted in #526 — concurrency buys nothing here and costs testability) answers “why not a database server?” in the same breath. Three standing rules, including: one writer per database file (reads welcome — that’s what WAL bought), and a fired trigger is answered with a measurement.
  • The triggers — the four, as a table with exactly what fires each, the measurement that answers it, and who observes it. First response to every trigger is its measurement row: trigger 1 starts “measure the actual write contention”, not “migrate”.
  • Consequences — what this rules out until a trigger fires (supervisor process or write-path web layer against a live file; sub-hourly profiles), migrations stay per-database/backup-first, candle read latency watched with a timer — and any reopening is a NEW record superseding this one.

keel/data/db.py::connect — the WAL comment gains one closing pointer: the standing decision (SQLite, one writer per file, the four triggers) lives in ADR 0002. This is the read-path guard: someone changing the process model reads connect() first.

docs/decisions/README.md — gains an Index listing 0001 (#439) and 0002 (#526); the README previously described the format but listed nothing.

Notes

…truncated — full description in #587.

docs(decisions): the commands/ survey — measured, classified, decided (#525) (#589)

Closes #525.

A survey, not a refactor — the deliverable is numbers and a recorded decision. Lives as ADR 0003 (docs/decisions/0003-commands-layer-survey.md), mirroring 0001’s Context/Options/Decision/Consequences shape (0002 is in flight on its own branch; this takes the next number).

Headline numbers

The issue’s premise dissolved before the survey ran: #541 (PR #554) deleted the TUI and console-only layers (24,709 → 13,552 lines, +297/−23,448 with tests), and the engine grew in the same window. Restating the issue’s table on main @ 16725c1:

at filingtoday
commands/23,18813,552
engine (7 modules)17,26020,798
ratio1.34x0.65x
Role split (the issue’s checklist item 1)
rolelinesshare
A. Shared report assembly (consumed by CLI + web/MCP)5,48640.5%
B. Services & decision orchestration (CLI-only)4,97536.7%
C. Front-end glue1,88013.9%
D. CLI-only report assembly + rendering7225.3%
E. Layer plumbing4893.6%

A line-level AST attribution: 1,016 render / 3,734 assembly+service / 1,292 click bodies / 3,320 helpers — and 4,190 lines (31%) of headers, docstrings and imports. The layer is substantially prose: the explanatory surface the issue’s “case that this is fine” describes.

Decision paths named (item 2)

_run_backtest (#335) no longer exists — the #390 C4 service extraction split it into resolve_rule_backtest+backtest_resolved+run_rule_backtest, core in strategy.backtest. Ten further decision-bearing paths are named in the ADR with file:line and, crucially, where each one’s policy lives: in every money case the deciding comparison (can_promote, transition, screen_asset, build_verdict, sizing.size) is in a compute module; commands/ holds sequencing and refusal wording. Two deliberate display-side mirrors (status._rail11_status, doctor’s ATR banding) carry inline arguments for why they are not policy.

Duplication quantified (item 3)

Traced report by report across CLI/web/MCP: zero near-duplicate render paths remain. Each load-bearing report is one shared core plus thin format projections (12–77 lines) — status (gather_status → render_human / status_payload, the web even imports _human_age), doctor (gather_findings → CLI lines / MCP JSON), purification (engine build_report → text / JSON), insights (build_* → CLI renders / web payloads). The historic duplication was real — the TUI — and #554 deleted 11,103 lines of it.

Test parity (item 3b)

tests/commands/: 11,031 lines, 527 test functions — more than any engine area’s suite — plus test_service_parity.py (CLI-vs-service byte-equality pins) and test_console_thinness.py (AST scan: no compute imports/calls, Decimal display-only, no broker construction outside seams).

The decision (item 4)

…truncated — full description in #589.

test(web): the thinness pin covers the MCP front-end too (#588) (#590)

Closes #588

One file changes: tests/commands/test_console_thinness.py. No handler, no service, no production line is touched.

What the scan now covers

_console_module_paths() scans a second glob next to the web one:

paths = sorted(glob.glob(os.path.join(REPO_ROOT, "keel", "web", "*.py")))
paths.extend(sorted(glob.glob(os.path.join(REPO_ROOT, "keel", "mcp", "*.py"))))

…so the six rules now hold over the MCP server (mcp/tools.py 8 handlers, mcp/server.py transport, mcp/__init__.py) exactly as they hold over keel/web/. The docstring says why both front-ends are in scope, citing ADR 0003 (docs/decisions/0003-commands-layer-survey.md) and its standing rule 2: any front-end consumes keel.commands.* services or the engine directly and never re-renders another front-end’s report from raw rows. keel/commands/mcp.py stays outside the set for the same reason serve.py always has — argv wiring and socket binding are service work (Rule 5’s own line).

The scan-honesty proof

test_the_scan_actually_scanned_the_console_layer now asserts {"mcp/tools", "mcp/server"} <= keys with the same loud failure message the web modules get, so a renamed handler module or a silently-empty glob fails the suite rather than leaving the rules green over less code. Because the pin passed vacuously green over all eight handlers on arrival, there is also a new positive control, test_rules_1_and_2_are_proven_false_capable_over_an_mcp_source: a synthetic mcp/tools source with a keel.execution.sizing import planted in it fires Rules 1 AND 2 over an mcp/ key, the audited build_report shape stays silent, and the same callee at a NEW site still fires. Rules 1-2 were factored out of the fixture into _compute_tree_findings (in _rule6_findings’ shape) so a control can run them at all — extraction is verbatim.

Allowlist entries added (entry-scoped, with reasons)
EntryReason
IMPORT_ALLOWLIST: ("mcp/tools", "keel.compliance.purification.build_report")The §65.9 report’s COMPUTE home, reached the same way the CLI’s keel purification body and the web view reach it — one implementation, three front-ends.
CALL_ALLOWLIST: ("mcp/tools", "_purification", "keel.compliance.purification.build_report")The purification tool is a JSON projection of the engine’s own report (ADR 0003’s table lists it as such); the entry names the exact enclosing function, so a second build_report call anywhere else in the package fails.

These are the same shape, and the same reason, as the _do_compliance_payload allowance the deleted TUI carried.

…truncated — full description in #590.

chore(release): 0.12.0 (#591)

What this is

The version-bump PR for 0.12.0, following #482/#515/#518’s precedent: one commit, every version and every == pin moves together, uv.lock moves with them. Per docs/RELEASING.md, merging this (not the workflow) is what sets the version — the release workflow then refuses to change it.

Minor, not a patch: 69 commits since v0.11.2 — 23 feat, 14 fix (non-merge subjects). New interfaces shipped (the web UI, declared parameter spaces, OrderSpec), so the minor is earned, not conservative.

What ships in 0.12.0

The headline items, enumerated from git log --oneline v0.11.2..HEAD:

  • The web UI rewrite (#531–#555): zero-dependency, view-source, on-device — static serving with checkable headers (#545), JSON reports sorted with Decimal (#547), the four-module client shell (#549), six views + SVG curve + stream that says when it stops (#550), an installable PWA whose worker structurally cannot cache a balance (#551), the old renderer deleted (#553), the curses TUI deleted with it (#554), worker update consent and the write boundary as a module (#555).
  • The broker-port flip completed (#524, #561–#563, #569): the default venue resolves through the registry, every order is an OrderSpec, the second Coinbase renderer is gone, the product-catalogue read lands (#561), the increment read goes through the port (#563), and the pre-port client’s grandfather clause retires.
  • Live stop management (#502, #521, #527): a per-cycle stop-management step (off by default), a bracket as one order kind because two legs race (#521), the stop a tranche was sized against is recorded because the current one ratchets away (#527), stops manage under the owning row’s policy per product, and a failed roll is loud and isolated (#519/#522 — the crash ledger is written before cancelling).
  • Declared parameter spaces (#528): each rule declares its parameter space; tuning reads the declaration, with explored-vs-declared honesty.
  • Alpaca cash posture (#372): Alpaca refuses margin accounts — cash-only by posture; PDT safety is the cadence, not the posture.
  • Installer fixes (#556, #557): the config shipped is confirm-mode production (not paper), the floor is the artifact’s (not the dev tree’s), the --force clobber warning, the DMG README floor tells the truth. Plus the installer/update line: curl|bash no-warning path (#511), Inno Setup installer with gated signing (#512, #438), per-release self-update (#510, #439).
  • Pooled-review tooling (#427): the 2026-09-30 pooled-review report driver, and the ledger-fees fix (fees are the exit leg — the measured-fee line cannot understate).
  • Floor: Python 3.14 required (#546).

Docs/decisions also land (ADR 0002 SQLite #526, the commands/ survey #525, the fill-model hazard note #529, the Jesse learnings PRD #530) — release notes will group them per the label table in docs/RELEASING.md.

Files bumped

…truncated — full description in #591.

fix(release): the Windows attestation subjects are newline-separated, not space-separated (#592)

The v0.12.0 dispatch (run 33146487866) failed at the Windows attestation step with Could not find subject at path out/*.zip out/*-setup.exe while BOTH files sat in out/ — the attest action treats the space-separated form as ONE path. The documented multi-subject form is newline-separated, one path per line. The step’s comment (and the structure pin) believed space-separated worked; both now say what the action actually does, with the failing run cited. Found while cutting v0.12.0; the release is being re-cut cleanly after this lands.

v0.11.2

Voir cette version sur GitHub ↗

Built from 829172f9ddfe. Version binds to this hash: keel --version reports keel 0.11.2+829172f9ddfe [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.2-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

fix(executor): quantize SELL base_size, without letting an unknown increment block an exit (#516) (#517)

Closes #516. Completes #513/#514, which fixed the BUY half and deliberately left this.

A SELL still emitted str(Decimal), so the first exit carrying a non-round quantity would have been rejected with the same INVALID_SIZE_PRECISION that killed the XLM entry — and an exit that cannot leave is a worse failure than an entry that cannot start.

The asymmetry is the design, not an oversight
incrementBUY (#513)SELL (this PR)
knownquantize down, sendquantize down, send
unknownrefusesend as-is, log

A refused BUY costs nothing; the opportunity passes. A refused SELL strands a position. Sending full precision at least sometimes works — a round quantity is accepted — so refusing would replace “sometimes exits” with “never exits” and leave the engine worse than before the fix.

Same reasoning for the protective bracket: one the venue refuses leaves the position unprotected.

Tests assert both halves side by side (test_a_buy_refuses_where_a_sell_sends) so that “make it consistent with BUY” fails loudly rather than looking like a tidy-up.

Three supporting changes
  • cb_client.list_products dropped the field. It projects a fixed set and base_increment/quote_increment were not in it — which is why there was nothing to quantize against. The venue always sent them; docs/experiments/2026-08-05-coinbase-asset-class-probe.py reads them off the raw response.
  • OrderIntent.base_increment carries it, following available_quote exactly: venue state the executor fetches and hands in, so _order_configuration stays a pure function of the intent with no broker access.
  • Cached in agent_state under base_increment:<product_id> with a 7-day TTL — matching rail 17’s cadence rather than inventing a second one, and needing no migration. One list_products call returns every product, so a miss caches all of them rather than re-fetching ~900 once per allowlisted asset.

The lookup never raises: venue error, no broker (paper mode), or a malformed field all return unknown, and the exit proceeds exactly as it did before this PR.

Smaller decisions worth a look
  • Down, not nearest, on both sides. Selling slightly less than held leaves dust; selling more is rejected for insufficient funds anyway.
  • A quantity smaller than one whole increment is sent unchanged, not suppressed. Silently retiring a holding keel still believes it has would be worse than an audited rejection.
  • Only the size is quantized. Bracket prices have their own increment and are out of scope here.
Verification
  • pytest -q — 4,497 passed, 3 skipped
  • ruff check keel tests packages — clean
  • mypy — clean, 352 source files

ruff format --check flags keel/execution/equity.py, pre-existing drift in a file this PR does not touch. CI does not run the format check.

Reviewer attention
  1. Is the 7-day TTL right? An increment changing is an announced venue event, and a stale value is still a multiple the venue accepts unless it got finer.
  2. Caching all ~900 products on a miss — one transaction, or too many agent_state rows for your taste?
  3. quote_increment is now plumbed but unused. #513’s QUOTE_INCREMENTS table could become a fallback rather than the source of truth — worth a follow-up, or leave it?

chore(release): 0.11.2 (#518)

Version bump so #517’s SELL-side size-precision fix can be released. RELEASING.md step 1 requires this in a reviewed PR.

Patch, not minor. No interface changed. Behaviour differs only in that an exit carrying a non-round quantity now serialises a value the venue accepts — and an unknown increment still sends unquantized rather than refusing, because refusing a SELL strands a position.

What moved

All eight distributions together — keel-trader plus the seven workspace packages — the == sibling pins, and uv.lock.

Verification
  • pytest -q — 4,498 passed, 3 skipped
  • ruff check keel tests packages — clean
  • mypy — clean, 352 source files
  • keel versions — ok: 8 keel distributions, all at 0.11.2
Context

0.11.1 shipped the BUY half (#513/#514). This ships the SELL half (#516/#517), so the two sides of _order_configuration are finally consistent in behaviour while remaining deliberately asymmetric in their unknown case.

Deployment is currently running 0.11.1+2560ffdf0c6e.

v0.11.1

Voir cette version sur GitHub ↗

Built from 2560ffdf0c6e. 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:

familyoutside allowance (120 bp taker)inside allowance (fee-free)
turtle_breakoutedge −39.9 pts, z −42.4 → not_distinguishableedge −2.8 pts, z −3.3 → not_distinguishable
rsi_meanrevedge −56.2 pts, z −31.8 → not_distinguishableedge −8.4 pts, z −3.7 → not_distinguishable
pullback_continuationedge −81.3 pts, z −142.5 → not_distinguishableedge −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:

familywinnertrain expheld-out expPBOverdict
turtle_breakoutSOL-USD−1.34−1.82 (14 trades)0.70REFUSED
rsi_meanrevETH-USD+220.980 over zero closed trades0.57REFUSED
pullback_continuationSOL-USD−4.79−2.56 (15 trades)0.84REFUSED

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
ToolWhat it returns
doctorThe same findings keel doctor computes (subscription freshness, rails, allowance, vetoes, data health, sizing admissibility), each naming the CLI fix
capabilitiesThe #453 inventory as structured rows; needs no database
profilesThe 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
ordersOrders audit-log rows, filterable by mode/product/status, newest last, bounded (default 50, max 200)
veto_logParsed executor.order_vetoed JSONL events since a timestamp (default 24h), bounded
purificationThe KB §65.9 report: owed by asset, tainted units, entries awaiting human classification
trialsTrials ledger counts + hash-chain verification errors + the most recent rows
reportsList/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
ModeSizing equity sourceRewards excluded?Evidence
Live / confirmconfig.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.sizeN — 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 > 0Y — immune (operator-chosen constant, not a balance read; contains no accruals by construction)keel/agent.py:516-517
Simaccount.mark_to_market(latest_price) = cash_usdc + exposureN/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 noteproducer: 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): https://github.com/CodeGateSoftware/keel/issues/353#issuecomment-5378800010 — 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:

familyarmnwin rateexpectancyPF
pullbackstatic3,2610.1435-68.000.0075
pullbacktrail 1.53,6970.0933-61.890.0017
rsistatic1,2010.2714-108.890.0776
rsitrail 1.51,2130.1665-111.400.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 keyCategoryFires when (threshold = the one keel doctor computes)
attestation.expiringattestationrail-17 withdrawal attestation has ≤2 of its 7 TTL days left, or has expired (doctor’s attest.withdrawals WARN/FAIL)
rail.armedrailrail 16’s streak halt arms (streak_halt_until > now) or rail 11’s drawdown reaches 20% (doctor’s HALTED/FAIL)
setup.unplacedexecutiona cycle detected an entry setup and could not place it (placed=False on any enter result)
allowance.nearing_exhaustionallowancemonth-to-date BUY spend ≥80% of the in-force rail-14 allowance (unlimited never fires)
feed.stale_open_positiondataa 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:

venuestatus spellingfill qtyaverage price
Coinbase (cb_client.get_order)OPEN while partially working, FILLED, CANCELLED/EXPIREDfilled_size (base)average_filled_price
Alpacapartially_filled -> port OPEN (translate.STATE_TO_PORT_STATUS)filled_qtyfilled_avg_price
Robinhoodpartially_filled -> port OPEN (doc-sourced spelling, see #198)filled_asset_quantityaverage_price
Krakenstub (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: https://github.com/CodeGateSoftware/keel/issues/502#issuecomment-5379673690
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.

v0.11.0

Voir cette version sur GitHub ↗

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

Install

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

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

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

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

Configure

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

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

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

Desktop app (macOS / Windows)

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

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

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

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

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

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

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

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

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

Other changes

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

Closes #428

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

Deliberate reconciliations so the file never contradicts itself:

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

No code changes; CI runs the usual gates.

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

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

The defect

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

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

profilemedian stop sf_N = risk_pct/snotional/tradevs $500/month
paper-hourly2.40%0.42~$4,2128.4x the whole month
paperforward13.86%0.07~$7221.4x the whole month

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

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

The fix

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

regime (hourly, b=6)κp_bevs reconstructed 14.9% win rate
inside allowance (φ=0, ψ=5bp)0.04214.88%+0.02%
outside allowance (φ=120bp)1.04329.18%−14.28%

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

Why both configs change

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

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

A drift worth knowing about

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

repo~/keel
config.paperforward.yaml10000500
config.paper-hourly.yaml1000010000

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

What is deliberately not changed

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

Scope

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

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

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

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

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

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

Three results from applying it:

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

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

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

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

Also a correction to us

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

Overlap flagged, not hidden

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

Full suite: 3797 passed, 3 skipped.

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

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

What v1 adds

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

Five findings. Two matter to us:

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

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

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

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

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

Also in this PR

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

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

Full suite: 3797 passed, 3 skipped.

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

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

Method

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

The organising finding

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

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

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

Design boundaries, recorded so they are not re-proposed

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

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

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

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

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

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

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

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

The short version

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

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

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

What the evidence says to build instead

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

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

The trust argument

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

Open questions in the PRD

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

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

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

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

The problem

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

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

The rule

keel_core.paths.state_root(), in order:

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

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

Why detection, not migration

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

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

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

Two deliberate decisions

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

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

Also

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

Verification

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

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

Closes the order half of #412.

What was run

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

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

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

symbolBTC-USD
side / typelimit buy, GTC
best bid at placement72705.56
limit price36352.78 — exactly 50% below the bid
size0.0001 BTC
notional$3.64
order id6a8738ba-836d-4afd-abd8-483d7e4d01c9
order-creating requests issued1

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

What the documentation had wrong

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

fieldfixtures claimedvenue actually sends
filled_asset_quantityunquoted numberQUOTED string, padded to 18dp
limit_order_config.asset_quantityunquoted numberQUOTED string, padded to 18dp
limit_order_config.limit_priceunquoted numberQUOTED string, padded to 18dp
limit_order_config.time_in_force"gtc"absent — accepted on the way in, never echoed back

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

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

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

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

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

The cancel is an acknowledgement, not a confirmation

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

teventstate
…18.409035order createdopen
…18.858613first GET /orders/{id}/open
…19.792632GET immediately after POST …/cancel/ returned 200open
…20.891890order settles cancelledcanceled

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

…truncated — full description in #451.

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

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

Why a browser

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

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

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

Read-only, structurally

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

Write actions land with D3, behind its gate.

Four layers, not one

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

layerwhat it refuses
bind 127.0.0.1the network
Host: header checkDNS rebinding — those packets really do arrive on loopback, and the header is the only place the lie shows
session tokenevery other local process, and cross-site requests
GET/HEAD onlythe write surface

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

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

No new dependencies, and no JavaScript

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

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

Pinned by the test that already pins the console layer

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

…truncated — full description in #452.

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

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

Scope, and why it stops where it does

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

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

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

What was wrong with the status quo

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

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

A declaration nobody checks is worse than none

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

mutationresult
remove a row from the inventoryfails — “these call sites gate an action that the inventory does not declare”
remove the gate call from autonomy.pyfails — “these inventory rows claim a gate that no longer exists in the source”

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

Two promises made mechanical

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

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

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

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

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

Verification

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

The decision this is waiting on

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

…truncated — full description in #453.

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

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

The observation this comes from

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

teventstate
…18.409035order createdopen
…19.792632GET immediately after POST …/cancel/ returned 200open
…20.891890order settlescanceled

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

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

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

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

outcomemeaningwho says it
CONFIRMEDthe venue states this order is terminal-cancelledAlpaca 204, Robinhood state: canceled, Coinbase success: true
ACCEPTEDthe request was taken; not settled yetRobinhood’s open after a 200
REFUSEDthe venue declined — already filled, unknown idCoinbase success: false, Alpaca 404/422
UNKNOWN5xx, timeout, or an answer with no row for this idany transport failure

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

What this deliberately does not do

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

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

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

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

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

Why this is safe to land on the live money path

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

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

Adapters report only what their venue can justify

…truncated — full description in #454.

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

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

Why a state model before a wizard

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

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

kindwho may do itexamples
mechanicala wizard, outrightconfig, database, migrations, rule seeding, first candles
judgementyou; a wizard may only recordevery Shariah attestation, every promotion
off-venueyou, elsewhere; keel cannot even lookUSDC Rewards, stock-lending and cash-sweep interest

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

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

Read-only, and it stays that way

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

Two bugs found by checking, not by testing

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

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

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

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

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

Smaller decisions

…truncated — full description in #455.

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

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

The guarantee that replaced “no POST at all”

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

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

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

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

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

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

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

The write surface is a closed set

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

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

actionsecond run
create configleft untouched (there is no force for a web caller to pass)
create databasemigrations re-applied, which are no-ops
seed rule librarypairs that already have a row are skipped

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

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

Five refusals before anything runs

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

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

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

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

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

Two things the tests caught, not review

…truncated — full description in #456.

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

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

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

The decision, and its consequence

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

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

The rule that is not obvious

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

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

Silence is the entire hazard. So:

installed → incomingdecisionproceeds silently?
nothingFRESHyes
0.10.0 → 0.11.0UPGRADEyes
0.10.0 → 0.10.0REINSTALLno — confirm
0.10.0 → 0.9.0DOWNGRADEno — confirm + warn
0.10.0 → nightlyUNCOMPARABLEno — confirm + warn

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

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

The trap this avoids

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

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

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

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

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

The message that reaches a desktop user

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

…truncated — full description in #457.

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

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

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

The build works. That is not the finding.

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

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

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

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

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

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

3. Metadata alone is not enough

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

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

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

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

Computed, never hardcoded

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

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

Verified against a build from the spec itself
$ ./keel versions
keel-broker-alpaca 0.10.0 · keel-broker-api 0.10.0 · keel-broker-coinbase 0.10.0
keel-broker-robinhood 0.10.0 · keel-core 0.10.0 · keel-trader 0.10.0
ok: 6 keel distributions, all at 0.10.0.

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

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

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

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

Verification

…truncated — full description in #458.

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

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

The bug, demonstrated before it was fixed

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

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

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

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

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

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

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

The fix, and what it deliberately does not weaken

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

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

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

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

Also: what makes the installer implementable

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

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

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

exit 0proceed without asking
exit 2stop and confirm

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

Verified against a real stamped bundle
$ keel --version          # from the keel repo AND from an unrelated git repo
keel 0.10.0+52a035aa1111 [release]

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

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

What is left in D5

…truncated — full description in #459.

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

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

The macOS half — built and run, not described.

The app launches keel serve, not the CLI

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

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

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

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

Verified the way Finder would do it

cwd /, stdin closed, no controlling terminal:

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

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

The release job, and why it defaults to not publishing

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

So:

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

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

Windows ships a zip, for now

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

What is pinned, since the job cannot run here

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

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

…truncated — full description in #463.

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

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

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

Three sources, and the order is the whole design
sourcewho sets it
1the real environmentwhoever launched the process
2the .env filethe operator, in a folder they chose
3the OS keychainthe first-run wizard

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

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

The source is carried, and that is not decoration

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

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

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

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

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

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

Verified in a frozen bundle, not assumed

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

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

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

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

…truncated — full description in #464.

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

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

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

That required moving a line I drew myself

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

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

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

what it ismay a form record it?
JUDGEMENTa decision only a human may make — a Shariah classification, a promotionno
OPERATOR_INPUTa fact only the operator possesses — an API keyyes

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

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

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

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

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

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

Verified end to end

Against a live server and the real macOS Keychain:

303  config    303  database    303  rules    303  credentials

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

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

Next

…truncated — full description in #465.

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

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

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

Windows signing is dropped as well

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

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

The no-warning path leads

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

And the instructions are actually instructions

“Click Open Anyway” is not one.

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

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

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

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

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

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

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

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

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

Exactly one slot, deliberately

Not a queue, not a pool.

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

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

A failed job stays visible

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

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

run_fetch needed no adapting

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

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

Refresh only while running

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

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

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

Starting a fetch with no credentials configured:

POST /setup/market_data  →  303, immediately

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

meta refresh: present while running, absent once finished

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

mypy caught a bug no test would have

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

Verification

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

Where D4 stands

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

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

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

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

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

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

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

stepstageamong the eleven?
assets_attestedpaperno
rule_promotedpaperno
subscription_attestedliveno
withdrawals_attestedliveyes
The invariant moves to where it always belonged

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

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

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

That last clause is not pedantry

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

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

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

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

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

attempt_promotion’s docstring is explicit:

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

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

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

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

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

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

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

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

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

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

The bug

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

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

Not disk space — 362 GiB free. Not corruption — PRAGMA integrity_check → ok.

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

Measured, same fetch, same machine, real venue
pollingjournaloutcome
every 5 s (the page)rollbackFAILED at 45 s, 31,709 candles
nonerollbackran 150 s, 108,202 candles
every 0.2 s (25× harder)WALran 150 s, 108,501 candles, 694 clean reads

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

The fix

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

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

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

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

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

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

Verified end to end after the fix

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

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

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

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

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

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

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

What happened

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

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

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

What changed

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

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

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

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

The open question this surfaces

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

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

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

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

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

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

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

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

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

What

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

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

chore(release): 0.11.0 (#482)

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

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

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

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

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

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

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

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

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

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

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

v0.10.0

Voir cette version sur GitHub ↗

Built from 24aa56f2ef58. Version binds to this hash: keel --version reports keel 0.10.0+24aa56f2ef58 [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.10.0-py3-none-any.whl
keel versions

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

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

Configure

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

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

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

Other changes

docs(research): money-management and fiqh source review (#366)

Fixes #365 (deliverable: the review document itself).

docs/research/2026-08-18-money-management-and-figh-source-review.md — eleven sources (10-post keeks series, Fortune’s Formula, the Abu Jib & Hashem 2019 Fiqh Academy paper) read and mapped against keel’s architecture.

Executive summary: (1) the headline finding is confirmatory — Kelly run on keel’s measured edges says bet zero, which is what keel already does; (2) keel’s risk-based fixed fraction is the strongest member of the fixed-fraction family the series covers; (3) adoptions proposed are instrumentation around that posture: R1 Kelly diagnostic in promotion/simulate output (report-only, adoptable now), R2 quarter-Kelly live-path ceiling (spec now, implement only when a rule promotes), R3 drawdown-throttled sizing as an hourly-paper experiment via the trials ledger, R4 CPPI framing of rail 11 in the runbook; (4) explicit rejects recorded with reasons: Optimal-f, streak-driven sizing, a Merton γ knob; (5) the fiqh paper’s ‘define each instrument type precisely before ruling, through qualified bodies’ is keel’s attested fails-closed screening argued from the fiqh side — reading-list and AAOIFI/IFSB watch items only, no code.

Docs-only PR; no sizing, rail, gate, or attestation changes.

docs(specs): PRD — keel-broker-alpaca, US equities via the broker port (#373)

Docs-only: the PRD for keel-broker-alpaca, captured from the operator conversation (a contributor has volunteered for the adapter build).

Tracked in milestone Phase 12 — Stocks via Alpaca: #369 adapter + conformance suite (Phase A — the advertised dream first contribution), #370 equities paper profile + attestation semantics + runbook (Phase B), #371 cost-fidelity restatement + DCA benchmark (Phase C), #372 cash-account/no-margin posture. Lineage: Phase 5 broker port (milestone 8), interacting with #233 (capability-based venue visibility).

Hard constraints carried in the PRD: long-only spot; cash accounts only (no margin — riba, and it sidesteps PDT’s margin threshold while T+1 settlement must be documented); per-(venue, instrument) attested screening with operator-supplied classifications; no live path in any phase — the promotion gauntlet applies unmodified, and commission-free is treated as not-cost-free (regulatory pass-throughs, spread, data tiers measured before any claim is believed).

docs(specs): refine the alpaca PRD against LEAN’s adapter and Alpaca’s product surface (#374)

Refines the Phase 12 PRD after exploring the two sources the operator named: QuantConnect/Lean.Brokerages.Alpaca and alpaca.markets.

New requirements: FR-9 session & calendar awareness (equities are not 24/7 — staleness rails must read market-closed on weekends/holidays; Alpaca’s 24/5 overnight sessions explicitly off); FR-10 corporate actions (splits/dividends/tickers as first-class events, fail-closed on unreconcilable actions, adjusted-vs-raw candle policy); FR-11 rate limits + paper/live host selection under #233. FR-3 extended with fractional/notional sizing, native bracket/OCO, MOO/MOC. New §5: dividend purification as a recorded operator-policy obligation.

Non-objectives hardened against the venue’s monetization surface: no stock lending (conflicts with qabd; interest-like income; opt-out operator-verified), no high-yield sweep (riba), no tokenized equities, no Alpaca crypto, no OAuth/FIX/MCP natural-language trading (the engine is deterministic and advertises it), Broker API recorded as the hypothetical future compliance-SaaS surface — subscription model, never trade commissions.

Docs-only; refines #369–#372 without changing their scope.

docs(fiqh): Lahlou speculation-risk scholarship analyzed and cited as method support (#377)

Fixes #376.

docs/research/2026-08-18-lahlou-speculation-risk-sources.md — analysis of Dr. Mohamed Talal Lahlou’s scholarship (doctoral thesis Marchés financiers islamiques et risque de spéculation, 2020; 2019 definition-and-tree of speculative situations; 2014 Cahiers recovery-rule article), mapped to keel: his method (structural definition + classification tree; situations over intentions) is the same genus as the rails; the recovery rule is rail 17’s French-language qabd pedigree.

Honesty guarantees baked in: no endorsement, review, or involvement by Dr. Lahlou is claimed — his skeptical position on trading stands unmodified; full texts were bot-gated so every inference is flagged against what was verifiable (titles, defense record, recension, citing theses); the works join fiqh-basis.md as method support, not attestation sources; the fiqh-basis subsection also adds the Abu Jib & Hashem 2019 Fiqh Academy taxonomy paper, completing the earlier source-review recommendation.

Docs-only; no screening, rail, or attestation changes.

docs: trademark and originality posture for venue adapters (#380)

Fixes #379.

  • README gains a Trademarks paragraph under Disclaimers: venue names are nominative use; keel is a client of each venue, not a product of it; no affiliation/endorsement/sponsorship; adapters are original implementations against publicly documented APIs.
  • Phase 12 PRD gains a Trademark and originality posture section: originality requirement, license+attribution rule for any ported third-party code, requirements-only reference study of Apache-2.0 projects (e.g. LEAN’s adapter), no venue branding.

Also records on #379: #369 is now operator-driven (the prospective contributor stays welcome anytime).

Docs-only.

fix(tui): render and scope the activity feed on the UTC clock it runs on (#381)

The bug

The activity overlay stamped rows in local time while the deployment defines its unit of work in UTC.

keel-live-run.sh gates and stamps once per UTC date, firing at the first trigger at or after 01:00 UTC (the instant turtle_breakout._completed_days stops withholding the daily bar that closed at 00:00 UTC). So the day’s only cycle lands at ~01:20 UTC. Rendered at UTC-4 that reads 21:20 on the previous date, and two things followed:

  • The newest row showed yesterday’s date all day — a deployment that had run correctly hours earlier looked a full day stale.
  • "today", being a local calendar day, excluded its own only cycle. Every day of the year, in every zone behind UTC. The default view of the overlay built to prove the agent is alive was permanently empty.

Reported from the live deployment: the feed showed 2026-08-17 21:20:06 as newest on 2026-08-18, and .keel-live-last-run said 2026-08-18.

The fix

The feed moves to the frame the work is defined in:

beforeafter
_stamp / _clocklocalUTC, under a when (UTC) heading
scope_start_tslocal calendar dayUTC calendar day
scope_labeltoday (2026-08-18)today 2026-08-18 UTC
_day_phraselocal calendar daysUTC calendar days
_next_due_linesaround 09:00 localaround 09:00 UTC

tui.py’s _human_dt stays local on purpose, and _stamp now documents why: autonomy deadlines and position open times belong to the operator’s day; a cycle does not.

Each row also gains an age column (47m, 22h, 3d), measured against ActivityFeed.now_ts so every row on one repaint agrees with the header above it. A UTC stamp cannot be compared to a local wall clock at a glance — that arithmetic is exactly what made a fresh feed look stale — so the row states the answer rather than leaving it to be computed.

scope_label drops the brackets around its date so that spelling UTC out in full keeps the default headline inside 80 columns (the existing test pins that).

Verified against the live log

today now shows the day’s cycle; before this it showed nothing.

scope: today 2026-08-18 UTC  ·  1 cycle  ·  43 older hidden  ·  press t to widen
   when (UTC)            age  mode    sig  blk  ent  exi  err  what happened
 ▸ 2026-08-18 01:20:06   22h  autonom   0    0    0    0    0  6 products / dca, turtle_breakout  quiet
 ▸ 2026-08-17 01:20:04   46h  autonom   0    0    0    0    0  6 products / dca, turtle_breakout  quiet
Tests

Full suite: 2986 passed, 1 skipped. ruff check clean, mypy clean.

New coverage, beyond retargeting the existing local-clock assertions:

  • test_a_cycle_early_on_the_utc_day_stays_in_today_all_day_in_a_zone_behind_utc — the production regression, written as the deployment actually behaves (cycle at 01:20 UTC, read at 23:28 UTC the same UTC date).
  • test_cycle_row_renders_utc_time_not_an_epoch_float_and_not_local_time
  • test_the_when_column_is_labelled_utc_so_the_frame_is_stated_not_inferred
  • test_the_age_column_answers_freshness_without_timezone_arithmetic
  • test_age_is_blank_not_invented_when_the_caller_has_no_clock
  • test_age_of_a_row_stamped_in_the_future_reads_now_not_a_negative_duration
  • test_header_and_row_columns_line_up_with_an_age_too

…truncated — full description in #381.

feat(broker-alpaca): keel-broker-alpaca — original Alpaca port adapter, conformance green (#382)

Phase A of the keel-broker-alpaca PRD (#369): packages/keel-broker-alpaca, an original implementation of the keel-broker-api port against Alpaca’s publicly documented Trading + Market Data REST APIs — raw REST over an injected requests transport (the keel-broker-robinhood convention), no alpaca-py SDK, no third-party adapter code. Zero changes under keel/.

FR-by-FR coverage
  • FR-1 Package — workspace-pinned pyproject (keel-core==0.9.3, keel-broker-api==0.9.3), py.typed, registered under keel.brokers as alpaca, wired into the dev group (optional venue, same posture as robinhood), mypy strict from birth, Dependabot entry + pip-audit export exclusion.
  • FR-2 Venue identity — venue = "alpaca", USD quotes, asset_classes = {"equity"} (the port’s keel-side vocabulary, not the venue’s casing).
  • FR-3 Orders — all four port kinds: market_ioc_quote → Alpaca’s notional market order (notional + type: market + time_in_force: day, as the docs require), market_ioc_base → fractional qty market, limit_gtc/stop_limit_gtc → gtc limit/stop-limit. Fractional sizes render positionally (format(d, "f")), never scientific notation. extended_hours: false is pinned on every body.
  • FR-4 Preview — Alpaca has no preview endpoint, so the preview is synthesized, following the sibling precedent for exactly this case (keel-broker-robinhood: synthesizes_preview=True / supports_native_preview=False, and its reasoning that no endpoint which prices a quantity is a broker’s quote). The book read is GET /v2/stocks/{sym}/quotes/latest; best_bid/best_ask ride in Preview.detail (feeding the #332 warning and the #350 spread gate), and pricing uses only the crossed side — ask for buys, bid for sells. ap/bp = 0 is the venue’s documented “no active side” and lands in Preview.errors, never as a price.
  • FR-5 Market data — 15Min/1Hour/1Day → FIFTEEN_MINUTE/ONE_HOUR/ONE_DAY; every other granularity refused with ValueError (the port’s sanctioned refusal). Pagination follows next_page_token, bounded like the sibling transports. Data tier is a declared capability: constructor-validated iex|sip, sent on every data request — never the venue’s silent sip default.
  • FR-6 Balances/positions — USD row with available = min(buying_power, cash): on a cash account (multiplier == 1) the gap between the two is exactly the unsettled T+1 proceeds, surfaced honestly. Long positions map qty_available → available, qty → total; short rows are skipped (long-only by construction).
  • FR-7 Fees — commission $0; sells modelled with the regulatory pass-throughs in fees.py as provenance-commented constants: SEC Section 31 $22.90 per $1M of proceeds (Alpaca’s own regulatory-fees page, live-read 2026-08-17; the SEC’s advisory 2026-2 moves it to $20.60/$1M as of 2026-04-04 — recorded as a documented re-measurement point per PRD §8’s regulatory-drift rule), FINRA TAF $0.000166/share capped at $8.30 (the cap is on Alpaca’s page; the rate is FINRA Schedule A §4(b)(7), in force since 2021-01-01). The model feeds Preview.est_fee on sells; buys are honestly zero. CAT is a documented sub-cent omission.

…truncated — full description in #382.

fix(broker-alpaca): NaN-safe money coercion, host-map-only construction, quoted path segments (#384)

Follow-up to #382 (merged pre-review): the adversarial review’s five findings, applied TDD red-first.

  1. [major] NaN/Infinity coercion hole — _decimal_or_none parsed 'nan'/'Infinity' successfully (JSON NaN arrives via parse_constant), crashing preview_order at bid > 0 and quote_size / crossed, get_balances at min(buying_power, cash), and silently poisoning Candle OHLCV — contradicting the preview docstring’s populate-errors invariant. Now is_finite()-guarded; three red-first tests cover quote sides, balances, and bar leaves.
  2. [minor] Host-map-only construction — the dead trading_host=/data_host= overrides (zero callers anywhere) are REMOVED; TRADING_HOSTS is the only endpoint→host map and the README’s “no configuration path from a paper credential to the live host, by construction” is now literally true (pinned by test).
  3. [minor] Quoted URL path segments — symbol/order-id interpolations percent-encoded (safe="") at all four sites; A/B-USD and a crafted order id can no longer reshape the request (asserted on the outgoing URL).
  4. [nit] Naive timestamps refused — offset-less RFC3339 no longer silently read as local time; ValueError per the module’s refusal convention.
  5. [nit] SEC fee framing — the provenance note now states the advisory rate (.60/$M) is already in force and Alpaca’s page is the stale side; the encoded venue-published .90/$M stays deliberate (conservative direction, ~$0.02/$10k) with the delta as the re-measurement trigger.

Gates: uv run pytest -q → 3110 passed, 1 skipped; ruff clean; mypy clean (249 files).

Refs #369 (Phase A hardening).

feat(port): session-aware venues — market clock in the port, closed-session semantics in the engine (#370 B1) (#385)

What

Phase B1 of #370 (FR-9, PRD docs/superpowers/specs/2026-08-18-keel-broker-alpaca-prd.md): the broker port gains a first-class session concept, and the engine consumes it — a closed equities market reads market closed, never feed stale.

Equities are not 24/7. keel’s feed-staleness rails and the agent’s cycle semantics assumed a 24/7 venue: on a weekend or market holiday, an equities feed that is simply closed would be reported STALE (the crypto semantics false-positiving), and cycles would log staleness-gated noise.

The port surface
  • BrokerCapabilities.session_bound: bool — REQUIRED, not defaulted (packages/keel-broker-api/keel_broker_api/capabilities.py). fake/coinbase/robinhood declare False (24/7); alpaca declares True. A default would be an answer to a question only the venue knows, and the likeliest default (24/7) is exactly the one that reads a closed equities venue as a stale feed.
  • Broker.market_clock() -> SessionState (port.py; SessionState in results.py: open | closed | clock_unavailable). Alpaca reuses Phase A’s /v2/clock read — is_market_open() now delegates to market_clock() so the two can never disagree. The 24/7 adapters answer OPEN as a constant with no network call (pinned by the no-transport adapter tests).
  • The conformance suite holds all four adapters to the new surface (conformance/suite.py).
FR-9 mapping (engine)
  • Cycle skip with kill-switch parity (keel/agent.py::run_once): the session gate sits directly after the kill-switch and skips exactly like it — no poll, no evaluation, no orders — logging agent.cycle_skipped with reason: market_closed (INFO; a weekend is expected) or the distinct reason: market_clock_unavailable (WARNING; “could not know” and “we know it is shut” are different operator facts). The kill-switch still outranks.
  • Fail-closed clock rule: a session-bound venue’s clock that cannot be read answers clock_unavailable — never an exception, never a guess of open. The engine belt-and-braces the same way (a third-party adapter that raises instead of answering still fails closed); the cycle never crashes on a clock read.
  • Recording for broker-free surfaces: each cycle writes the venue’s answer to agent_state (market_session / market_session_ts).
  • fetch --check (still offline) reads that recording via agent.recorded_market_closed: stale-but-present series render as the distinct CLOSED state and do NOT count as missing/stale for the exit code — a weekend must not page an operator. Deliberate carve-outs: missing still alerts (a closed venue serves history); clock_unavailable does not defuse alerts (fail-closed for trading, fail-loud for alerting); the recording is trusted only for rail 12’s feed-heartbeat window (interval_sec * FEED_STALENESS_CYCLES) so a dead agent cannot silence alerts forever.
  • keel status / keel tui: a distinct session line directly under the kill-switch line (the #345 rail-17 line precedent; no paper carve-out — the session gate skips paper cycles too, so the same line is truthful in every mode).
Crypto unchanged — guaranteed at every seam

…truncated — full description in #385.

feat(paper-equities): the equities paper profile — tracked config, venue selection, deployment artifacts, runbook (#370 B2) (#386)

What

Phase B2 of #370 (PRD docs/superpowers/specs/2026-08-18-keel-broker-alpaca-prd.md, Phase B / §5, FR-9/FR-10 posture): the equities paper profile — keel’s daily turtle rules on US equities via Alpaca’s PAPER API, on their own database, accruing hash-chained paper evidence end-to-end — plus the minimal engine wiring it needs: config-driven venue selection.

The profile (tracked, mirrors the paper-hourly pattern)
  • config.paper-equities.yaml — broker: {name: alpaca, endpoint: paper, data_feed: iex}; market_data.granularities is ONE_DAY only (a daily clock — hourly bars exist only within sessions and daily rules do not read them; stated in comments); interval_sec: 86400; weights sum to exactly 1.000; risk_pct: 0.01; caps mirroring the paper profiles; the paper-hourly-style honesty caveat in the header.
  • Deployment artifacts — com.keel.paper-equities.plist (strict-XML validated; schedule below), paper-equities-run.sh (UTC-day stamp, set -e, stamp only after a successful cycle, session-window guard), keel-equities wrapper (config + keel-equities.db travel as a pair).
  • Runbook — docs/operator-runbook.md gains “The equities paper profile”: why it exists (evidence on a session-bound venue; the same honest no-edge caveat), bootstrap (Alpaca paper account, env vars, keel migrate --db keel-equities.db, rules add --kind turtle_breakout --product SYMBOL --params '{"granularity": "ONE_DAY"}' + rules promote --force with the documented bypass rationale, fetch warm — B1 makes weekends quiet), the fourth profile-comparison column, attestation semantics (operator-supplied per (alpaca, SYMBOL), AAOIFI/IFSB-class sources per the fiqh source review; the engine never classifies), rail 17 as ACATS transfer-out, the T+1 × daily-cadence interaction (immaterial for entries; exits never spend), and the two operator-verified opt-outs (stock lending OFF — qabd; high-yield sweep OFF — riba; where to verify each in the Alpaca dashboard). .env.example wires the new key names.
Venue selection (the engine half)

A new broker: config section — name (→ keel.brokers entry-point discovery), endpoint (paper|live), data_feed (iex|sip), both validated at load — threaded through the one broker-construction seam every CLI/agent/TUI path shares (keel/commands/_common.py::_build_broker), plus telemetry’s bind_venue in _load_cfg. Secrets follow the CDP pattern: load_alpaca_secrets reads ALPACA_API_KEY_ID/ALPACA_API_SECRET_KEY from the environment or .env; the missing-keys error names the venue and both variables.

When the section is absent, the Coinbase construction path is byte-compatible — pinned by a test that drives the real _build_broker against monkeypatched RESTClient/CoinbaseClient and asserts the exact kwargs — and every pre-existing test stays green unchanged. The config golden baseline is regenerated deliberately (its own documented step) for the new field.

The candidate set — PAPER CANDIDATES

…truncated — full description in #386.

docs(specs): PRD — the TUI as keel’s operator console (#393)

The operator-console PRD, with the full current feature inventory (§2) and the menu-tree v1 (§3).

Milestone Phase 13 — TUI: the operator console: C1 #387 service-extraction audit (foundation — TUI dispatches to exactly what the CLI calls, no duplicated logic), C2 #388 console shell + guarded profile switching across the four deployments, C3 #389 compliance menu + the scout-results admission handler (propose → screen → human-typed attest, via the existing admission services), C4 #390 rules + research readers, C5 #391 trading + data menus, C6 #392 safety & polish (adversarial pass on the typed-confirmation contract and live guards).

Hard fences (PRD §4/O3): no new business logic — fix the service and both front-ends get it; no auto-trading born in the TUI; typed actions keep their typed prompts; the scout handler never auto-attests.

Docs-only.

docs(specs): console PRD — venues/brokers visibility and the help & glossary system (#395)

Operator additions to the console PRD: O7 venues/brokers visibility (a brokers service + keel brokers list + the TUI Venues browser; capabilities not key-presence; no secrets) and O8 newbie-friendly help & glossary (per-screen/per-action plain-English help, single-source definitions, typed-prompt disclosure). Adds C7 (#394) to the phasing, the Venues submenu to the menu tree, and success criteria 6-7. Docs-only. Refs #394.

docs(specs): console PRD — the session banner (O9) (#396)

Operator addition to the console PRD: O9 — every screen’s header shows the active profile + market session state + the venue market clock (next open/close for session-bound venues via a small port extension carrying next_open/next_close; ‘24/7’ for always-open; CLOCK UNAVAILABLE fail-loud when stale/absent, matching fetch --check). Applies to live and paper profiles alike; sourced only from the recorded session state and the market_clock() service — no TUI-side calendars, no new session logic. Woven into the menu tree (per-screen header line), C2’s scope (#388), and success criterion 8. Docs-only. Refs #388.

docs(specs): console PRD — parameter-level help, single-sourced from the rule classes (#397)

Operator addition to O8: parameter-level help. Every rule-kind parameter (your example: turtle_breakout’s entry_lookback, exit_lookback, adx_period, adx_threshold, atr_period, atr_stop_mult, use_macd_confirm, s1_filter, min_volume_filter, volume_ma_period, volume_mult, target_rr, granularity — and every other kind’s equivalents) gets an in-console plain-English explanation: meaning, what changing it does and which gate it feeds, type, default, shipped range.

Single-source rule: the truth lives in the rule classes (RULE_REGISTRY constructors); missing per-parameter docstrings get added AT THE CLASS, and the help renders them by introspection — no hand-maintained duplicate table that drifts from the code. Contextually invokable wherever params render: rules list detail, add forms, backtest views. Scope note added to #394 (C7). Docs-only.

docs(specs): console PRD — the ‘Shariah in force’ browser (O10) (#398)

Operator addition to the console PRD: O10 — ‘Shariah in force’ for the current session (scope noted on #389/C3).

A Compliance submenu showing what the engine enforces for the ACTIVE profile, strictly read-only from existing sources: per-instrument attestations with attributed sources/rulings/dates (via the assets list service) and exemptions in effect; the fiqh-derived rails each with a plain-English explanation and a citation into docs/fiqh-basis.md (never a TUI-written summary); and the standing honesty lines — not a fatwa engine, no scholarly review has occurred — always visible. The help menu (O8) explains the fiqh vocabulary (qabd, riba, gharar, maysir, attestation, exemption, purification) anchored to fiqh-basis.md so definitions cannot drift from the document that states them. Success criterion 9 added. Docs-only. Refs #389.

refactor(cli): extract command bodies into the shared service layer (#387 C1) (#399)

C1 of the TUI-operator-console PRD (§5): O2 — one implementation, two front-ends. Every CLI command body in keel/cli.py that carried logic now delegates to a service in keel/commands/*; the wrapper parses click options, builds the broker lazily at the _build_broker seam, calls the service, and prints/raises. keel/cli.py shrinks by ~1280 net lines and gains no behavior.

Audit → destination
commandclassshared implementation
fetch(b) extractednew keel/commands/fetch.py — assess_products (window-bounded sweep), render_freshness, run_fetch (check/skip/warm/repair flow, FetchResult)
monitor(b) extractednew keel/commands/monitor.py — monitor_cycle, run_monitor (FR-9 session-aware loop, once-per-state-change skip lines)
simulate(b) extractednew keel/commands/simulate.py — run_simulation + the whole assembly (candles, #259 slippage pass, account metrics, tier matrix, verdict, trials row, report/artifact write)
assets screen / holdings / discover(b) extractednew keel/commands/assets.py — screen_product (THE admission gate), market_facts, VENUE, gather_holdings+render_holdings, run_discovery+render_discover, screen_products, broker_auth_hint
agent’s confirm gate(b) extractednew keel/commands/confirm.py — _interactive_confirm, preview reading/rendering, all markers
kill / resume / resume-entries / record-flow / reset-hwm(b) extractednew keel/commands/trading.py — the state mutations + render_loop_result (typed prompts stay front-side per O3)
pnl(b) extractednew keel/commands/pnl.py — build_pnl_report + render_pnl_report
purification(b) renderer extractednew keel/commands/purification.py — render only; compute was already keel.compliance.purification
db trials withdrawals autonomy rules subscription status insights versions tui(a) already sharedkeel/commands/* modules; keel.agent; keel.data.*
assets attest/attest-instrument/exempt/unexempt/list/propose(a) already sharedRepository writes / keel.proposer
agent cycle/loop(a) already sharedkeel.agent.run_once/loop
init / init-config / migrate(a) — deliberately NOT extractedtemplate read + keel.data.db.migrate; scaffolding ops absent from the TUI menu tree

Also: keel/commands/admission.py now imports the discovery defaults from keel/commands/assets.py instead of mirroring the CLI options by hand (one home for DEFAULT_MIN_QUOTE_24H_VOLUME / DEFAULT_DISCOVER_LIMIT), and keel/commands/tui.py’s two lazy from keel.cli import _screen_product imports now come from the service layer — no cycle dodge left.

Byte-compatibility guarantee

…truncated — full description in #399.

docs(specs): console PRD — the strategy console (O11) (#400)

Operator additions to the console PRD, owned by C4 (#390): O11 — the strategy console: (1) simulations run from the console with results displayed (GO-LIVE/TRAIN-MORE verdict, gates, DCA benchmark, tier matrix; report linked from the Research readers); (2) the tried-vs-used ledger — every strategy with its lifecycle status AND the machine’s recorded reason it isn’t in use (failing promotion floor, insights distance, disabled/demoted context); (3) add-a-strategy in-console with parameter help at each field; (4) retry — re-backtest + re-promote (--force typed) and rules enable as the restore path. All through the C1 services. Docs-only. Refs #390.

feat(tui): the console shell — menu navigation, guarded profiles, the session banner (#388 C2) (#401)

What

C2 of the TUI-operator-console PRD (issue #388): the console shell around the existing dashboard, profile switching across the four deployments, and the session banner on every screen. The dashboard stays the landing screen; nothing working was rewritten.

The shell’s structure
  • keel/commands/console.py (new) holds the pure model: the menu tree, the profile convention, the switch/guard logic, and the banner builders — directly unit-testable without curses, mirroring tui.py’s pure-model/thin-loop split.
  • run_live gains three modes — menu (the PRD §3 tree, cursor + 1-9 ordinals), profile (the deployment menu), placeholder (a future slice’s notice) — and m in normal mode opens the menu. Every screen, all ten modes, is prepended the two-line session banner (fail-soft read).
  • run_live takes the shell as an optional console_binding: a caller that passes none gets the pre-C2 dashboard byte-for-byte — which is what keeps every pre-existing test unchanged. --once heads its frame with the same banner.
  • Only Dashboard / Profile / Help act in this slice; Trading, Rules, Compliance, Data, Research, Account render a “lands in C3/C4/C5 — navigation only” notice (owners per the PRD §5 phasing; Account rides with C5, commented as such).
The profile convention (O4)

The four deployments are discovered from the tracked config files present in the working directory — the same place the CLI resolves its relative --config/--db paths. The pairs are what the wrappers (keel-paper, keel-live, keel-paperhourly, keel-equities) and the runbook’s deployment table pin:

profileconfigdbguarded
paper-forwardconfig.paperforward.yamlkeel.dbno
liveconfig.live-sandbox.yamlkeel-live.dbyes
paper-hourlyconfig.paper-hourly.yamlkeel-paperhourly.dbno
paper-equitiesconfig.paper-equities.yamlkeel-equities.dbno

The convention is stated as a comment in console.py; no new registry file. A switch rebinds config and db together through the same _load_cfg/_open_repo loaders every CLI command uses (ConsoleBinding.open_state), so one action rebinds every screen; a pair not in the registry — a config on another deployment’s db — is refused outright. Selecting LIVE asks an explicit y/N at the terminal (a confirm step, deliberately not O3’s typed contract: it changes what the operator is looking at, not what the engine does) and, once active, the header marks it unmistakably (LIVE (REAL MONEY), alert style). No secrets anywhere in the header or profile display — file names and venue declarations only.

The port schedule extension

market_clock() is untouched. Broker.market_schedule() is added, returning a frozen MarketSchedule(state, next_open_ts, next_close_ts):

…truncated — full description in #401.

feat(tui): the compliance menu — admission flows, the scout handler, Shariah in force (#389 C3) (#403)

C3 of the TUI-operator-console PRD (issue #389): the Compliance sub-menu (PRD §3), the scout-results admission handler (O6) and the “Shariah in force” browser (O10) — all dispatching to the existing C1 services; the TUI stays thin (O2). Test-first: the new tests were red before the implementation landed.

Entry → service dispatch (every entry, an existing service or repository function)
Compliance entryDispatches to
screenadmission.build_screen_report over assets.screen_product (the one admission gate)
proposeadmission.build_propose_view over proposer.parse_proposal/build_proposal_report
attest (typed)Repository.upsert_asset_attestation — the exact call/args keel assets attest makes, same echo line
attest-instrumentRepository.upsert_instrument_attestation (product uppercased, wrapper vocabulary = KNOWN_WRAPPERS)
exempt / unexemptRepository.upsert_screen_exception / delete_screen_exception (criterion restricted to WAIVABLE_CRITERIA, blank rationale refused)
holdingsassets.gather_holdings + render_holdings behind ONE Enter-gated get_accounts (opens ARMED, holds the result — the discover overlay’s own gating story)
discoveradmission.build_discover_report over one Enter-gated list_products
Scout results…the browser below (O6)
Shariah in force…the browser below (O10)
subscription show / attest / setthe bodies extracted into keel/commands/subscription.py (subscription_show_lines / apply_subscription_attest / apply_subscription_set) — one implementation, two front-ends; the CLI commands delegate, byte-compatible (their tests unchanged and green)
withdrawals attest (typed)the CLI’s own two set_state writes + echo lines
purificationcompliance.purification.build_report + render_purification_report (report-only)
The typed contract (O3), sacred
  • withdrawals attest --enabled keeps the CLI’s own gate — clis_typed_withdrawals_gate calls _require_interactive_confirmation("attest withdrawals as ENABLED", …) verbatim, rendered in-console via the curses suspend/restore dance, failing closed; --suspended stays ungated (the CLI’s asymmetry). A declined gate means not a single state row is written (pinned).
  • attest — marked “(typed)” by the PRD §3 tree — ends its form with a typed gate demanding the operator type the asset code back (the scout flow’s attest step is the same form). Never pre-filled, never piped; a wrong phrase writes nothing (pinned for both the menu form and the scout flow, including end-to-end through the live loop).
  • Forms collect their fields at the terminal inside the console session (_run_terminal_form: suspend → prompts → restore) and their confirmation line toasts on the menu — every write shows what it did.
The scout-results handler (O6)

…truncated — full description in #403.

feat(tui): the strategy console and the research readers (#390 C4) (#404)

The console’s fourth slice: the Rules menu becomes the full try/evaluate/retry loop of PRD O11, and the Research menu lands the O5 evidence readers. Everything dispatches to the C1 service layer; no sizing, gating or reporting math is born in the TUI.

The tried-vs-used ledger’s sourcing (O11.2)

Every rules-table row rendered with its lifecycle status AND the machine’s recorded reason it sits there:

  • can_promote’s own reasons — the exact lines rules promote prints on a refusal: which floor failed (n_trades N < min_trades 100, expectancy/rr/win_rate), and the G4/PBO axis honestly NOT RUN — computed from a fresh backtest over the repo’s cached candles at the config’s own fee (_backtest_fee + _describe_fee provenance).
  • the insights service’s promotion-gate distance for paper rules (build_rule_track_record/track_record, the trades_remaining wording).
  • the rows’ own promoted_at/demoted_at stamps for live/disabled rows.
  • A rule with no cached candles renders “no backtest on record” — the honest absent case, never a TUI-authored narrative.

The ledger computes once per entry (every row’s backtest is real work) and is held while open. Its detail view renders every param through describe_params.

The simulate UX decision (O11.1)

An ARMED view showing the target report path first — Enter IS the confirm step — then run_simulation on the active profile’s config/db with the CLI’s own defaults (5y, $500/month, allowlist products, history fetched when the cache does not cover the window, mirroring the CLI). The run blocks the loop exactly like f fetch does — the CLI’s own UX, mirrored honestly. The progress lines the CLI streams are collected and shown at the head of the results; the verdict/report render verbatim under a pinned verdict+path footer (pinned_frame); closing re-arms. The report lands in the directory the Research/promotion-reports reader lists, newest-first.

The extraction done first (C1 style, byte-compatible)

The validation and write logic of rules add/backtest/promote/demote/disable/enable moved out of the click bodies into keel.commands.rules services (add_rule_row, run_rule_backtest, attempt_promotion, apply_rule_*) that echo through injected stdout/stderr sinks. The CLI wrappers dispatch and are byte-identical — verified against origin/main across 17 command scenarios (diffed outputs, exit codes), and by the untouched test_cli.py/test_rules_add.py suites. The G4 loader stays injectable (load_pbo) so the _load_pbo test seam keeps working unchanged. --force on the gated path loads config only where the old body did.

O3 held: promote asks an explicit y/N in-console; --force runs only behind the CLI’s OWN _require_interactive_confirmation typed gate (clis_typed_promote_force_gate, exact phrase, never pre-filled, failing closed); no auto-promotion anywhere.

describe_params design

…truncated — full description in #404.

feat(tui): the trading and data menus (#391 C5) (#405)

The Trading and Data menus — C5 of the TUI-operator-console PRD

Per the PRD §5 C5 + O3: every entry dispatches to the C1 service layer (one implementation, two front-ends), and every typed action keeps the CLI’s OWN typed prompt rendered in-console — identical prompts, logs and audit events to the CLI; never piped, never pre-filled, never bypassed. No TUI-originated order path bypasses the agent pipeline (pinned: the cycle’s confirm_fn IS keel.commands.confirm._interactive_confirm, the same object keel agent hands the executor).

Entry → service dispatch

Trading menu (keel/commands/trading_console.py):

entrydispatch
agent cycle (single)ARMED confirm → agent.run_once via run_agent_cycle (confirm_fn = the CLI’s own _interactive_confirm); result held: render_loop_result + render_blocked_entries, a skip’s logged reason verbatim
monitor poll (single)ARMED confirm → monitor.monitor_cycle, the CLI’s own products/granularities/interval derivation
autonomyrepo.set_autonomous with the CLI’s own asymmetry: ON behind autonomy.autonomy_on_gate (extracted, byte-identical wording), OFF ungated; result lines are the CLI’s own
record-flow [typed]the CLI’s own order (gate naming the RAW amount first, then validation), parse_flow_amount, trading.record_flow, render_flow_recorded
reset-hwm [typed]clis_typed_reset_hwm_gate → reset_high_water_mark
resume-entries [typed]clis_typed_resume_entries_gate → clear_consecutive_loss_halt
killengage_kill_switch, immediately — the CLI’s own contract is one key with no ceremony; none added, none removed
resume [typed]clis_typed_resume_gate → disengage_kill_switch

Data menu (keel/commands/data_console.py):

entrydispatch
fetchARMED plan (products × granularities × 5y window from the ACTIVE profile) → fetch.run_fetch itself, lazy broker factory, blocking like the CLI, streamed lines held and rendered verbatim
fetch —checkits own ARMED entry; the exit verdict (the service’s own error) renders, pinned under the scroll
repair gapsARMED confirm → run_fetch(repair_gaps=True), per-series outcomes in the held stream
freshness overviewoffline, per poll: run_fetch(check=True)’s own sweep — never constructs a broker (pinned)
db importpath form → db.validated_import_dir (the CLI’s own click.Path check) + csv_import.import_dir + render_import_result; a bad path refuses with the CLI’s exact line, pinned byte-for-byte against a real CliRunner run
Extractions (C1 style, CLI byte-compatible, its tests unchanged)
  • keel/commands/trading.py — the RESUME / RESUME-ENTRIES / RESET-HWM / RECORD-FLOW gate wording (single home, the withdrawals.py pattern), record_flow_action, parse_flow_amount, the four confirmation lines (KILL_ENGAGED_LINE, …), render_flow_recorded, render_blocked_entries
  • keel/commands/autonomy.py — autonomy_on_gate, autonomy_expiry, render_autonomy_on, AUTONOMY_OFF_LINE
  • keel/commands/db.py — validated_import_dir, render_import_result
Gate parity proofs

…truncated — full description in #405.

feat(tui): venues visibility and the help & glossary system (#394 C7) (#406)

Closes the console PRD’s C7 slice: O7 (venues/brokers visibility, service-first) and O8 (the newbie help & glossary system). Test-first: the new suites were written red (tests/commands/test_brokers.py, tests/commands/test_help_console.py — both failed at import before a line of implementation), then made green.

The brokers service (O7) — one service, two front-ends

keel/commands/brokers.py::list_installed_brokers() walks the entry-point registry (discover_brokers()), constructs each adapter credential-less, and renders one frozen BrokerInfo row per installed adapter:

name, venue, deployment ("wired-for-deployment" | "optional-dev-venue"),
session_bound, quote_currencies, asset_classes, supported_orders,
preview ("native" | "synthesized" | "none"), supports_fee_summary,
declared_endpoints, supported_data_feeds, package_version
  • Every field derives from the adapter’s own declarations (capabilities(), the new DECLARED_ENDPOINTS/DECLARED_DATA_FEEDS on AlpacaAdapter — derived from TRADING_HOSTS/SUPPORTED_DATA_FEEDS, never a second list). Pinned: the payload fields equal the adapters’ declarations, per field.
  • No secrets, no key-presence inference (#233-aligned): the field set is closed and pinned by test; the payload is JSON-scanned for secret vocabulary; the screen and CLI both carry the shared NO_KEY_INFERENCE_LINE.
  • Wired vs optional is ONE explicit constant (WIRED_FOR_DEPLOYMENT = {coinbase, alpaca}) with its reasoning in place — no registry signal carries it.
  • Front-ends: keel brokers list (+ --json following status --json’s convention, no disclaimer after JSON; the README’s trademark line in the docstring) and the TUI Venues browser under Profile (console.build_venues_lines): same payload via the service’s shared capability_facts wording, the SELECTED adapter highlighted [selected], the active deployment’s binding (config+db pair; endpoint/data feed where the venue declares them) named. The one-payload/two-front-ends equality is pinned by test; the browser’s facts are read once per entry, never per poll.
The glossary (O8) — one source, anchored

docs/glossary.md is the ONE new hand-written file and the only place console terms are defined. Seeded with the console’s needed terms (rail; attestation + instrument attestation; exemption; screening; promotion gate; paper/live mode; kill switch; autonomy; qabd; riba; gharar; maysir/maisir alias; purification; session-bound venue; market clock; trust window; DCA benchmark; granularity; trials ledger). Design as built:

…truncated — full description in #406.

fix(tui): true glossary definitions, resilient broker listing, complete contextual help (#407)

Follow-up to #406 (merged pre-review-fix): the adversarial review’s three blockers + two majors + five minors/nits, applied red-first.

Blockers: ? was dead in the discover overlay (and the every-mode structural test’s regex block-extraction was dishonest — absorbed trailing code into the last block, now AST-based); the glossary’s rail entry said nineteen rails (it is eighteen — guards.py and fiqh-basis both say so); the promotion gate entry INVENTED a DCA-benchmark floor the gate does not have — rewritten to the real axes (min_trades/min_expectancy/min_rr/min_win_rate + the PBO∧degradation-slope conjunction), pooling’s true scope (per parameter SET, sample-size axis only; the overfitting gate is not pooled), and the DCA comparison’s actual home (simulate’s report, not the gate).

Majors: a raising adapter killed the whole brokers listing (and could crash the TUI) — the service is now total with honest per-adapter error rows on both front-ends.

Minors/nits: stale keys-&-safety text (incl. the pre-C3 placeholder paragraph) updated to the live console; m closes the context overlay; ? works in help mode; WIRED_FOR_DEPLOYMENT now drift-guarded against the tracked configs’ actual selections; dead helper wired; garbled glossary sentences repaired; record-flow/reset-hwm typed disclosures; two more verbatim fiqh pins.

Gates: 3605 passed, 3 skipped; ruff/mypy clean. Refs #394.

feat(tui): the console completed — account menu, ceremony audit, thinness pin, runbook (#392 C6) (#408)

C6, the final slice of the TUI-operator-console phase (PRD §5 C6, §6.2, O2/O3/O4). Four deliverables:

1. The Account menu — the last placeholder (keel/commands/account_console.py)
  • pnl — the EXACT keel pnl report through the C1 services (commands.pnl.build_pnl_report + render_pnl_report) over the active deployment’s imported transactions; when nothing is imported it renders its honest empty state naming keel db import, never a confident total realized P&L: 0.
  • versions — the same lines keel versions prints, through ONE shared renderer extracted to keel/commands/versions.render_versions_lines (CLI output byte-identical, existing tests unchanged); disagreement renders loud where the CLI exits non-zero; the environment scan runs ONCE per entry, rows held (the venues browser’s contract).
  • The whole branch is READ-ONLY — no write path at all — and keeps the console contracts: banner-aware, m-close, cursor reset on entry, ? contextual help (three new modes registered in help_console).
2. The architectural thinness pin (tests/commands/test_console_thinness.py, PRD §6.2)

An AST scan over console.py + tui.py + every *console*.py with four rules:

  1. No compute-tree imports — nothing from keel.strategy.*, keel.execution.{guards,sizing,executor}, keel.compliance.{screen,purification}, keel.analysis.* except constants/types (verified against the source module’s own AST — the gate-wording/screen-vocabulary imports) and the audited function imports.
  2. No compute-tree calls — any call resolving into those trees must match a (module, enclosing function, callee) allowance, each justified inline (the ledger’s insights-mirrored paper-gate read; the Enter-gated verdict dispatching to the engine’s own backtest+gate; the retry’s next_status wording; the shared purification renderer; the executor’s two READ helpers). Allowances are site-scoped and asserted to name real callees, so they cannot leak.
  3. Decimal is display-only — no arithmetic operator over a Decimal(...) construction.
  4. No broker construction outside the seams — only inside the build_broker/build_client lambda handed to a service (the run_fn seam) or the audited bounded read sites.

Proven false during development on deliberate scratch violations of every rule (sizing/guards/screen calls, Decimal math, a stray _build_broker) — each failed the pin by name — then removed.

3. The typed-confirmation + live-guard adversarial audit, as tests (tests/commands/test_console_ceremony.py)

…truncated — full description in #408.

fix(brokers): correct rh_best_bid_ask to what the venue actually sends (#414)

Closes the fixture half of #413.

The wrong claim

tests/fixtures/rh_best_bid_ask.json carried:

{"next": null, "previous": null,
 "results": [{"symbol": "BTC-USD", "timestamp": "...", "bid": "65380.00", "ask": "65480.00"}]}

A tidy 15 bps spread BTC-USD does not produce, and next/previous this endpoint does not send.

What the venue actually returns

Raw body, verbatim:

{"results":[{"symbol":"BTC-USD","timestamp":"2026-08-19T19:20:41.579217945Z","bid":"68329.2","ask":"68324.17"}]}

Three samples ~2s apart, five pairs, 2026-08-19:

symbolask − bid ①②③
BTC-USD−0.86 bps−0.28 bps−1.38 bps
ETH-USD+0.10 bps−0.29 bps−1.29 bps
DOGE-USD−0.11 bps−0.11 bps−0.59 bps
XLM-USD+3.21 bps+3.63 bps+5.09 bps
ADA-USD+2.41 bps+2.41 bps+2.49 bps

Not a label swap — that would invert every pair equally. The legs are sampled independently and then stamped with one timestamp the row does not earn: where the true spread is thinner than the sampling jitter (BTC, ETH, DOGE, all well under 1 bp) they land out of order; where it is wider (XLM, ADA) the ordering survives.

Why the probe never caught it

scripts/robinhood_smoke.py compares shapes, never values. It matched this fixture on every run. This is #217 F4’s failure inverted — that fixture invented keys the venue never sends, this one invented an ordering it does not produce, which is harder to spot because a tidy spread is what a reader expects to see.

The fix
  • rh_best_bid_ask.json — an observed crossed BTC-USD row, no next/previous.
  • rh_best_bid_ask_uncrossed.json — new, an observed XLM-USD row.

Both, because either alone would be a fresh false claim: a lone crossed row asserts “always crossed” as confidently as the old one asserted “never”. That is #230’s lesson — a fixture can be wrong by being unrepresentative, not only by being invented.

Two tests asserted bid < ask, one per side of the port, and are inverted. The adapter-side test now pins that the endpoint admits both orderings, and that the crossing is jitter-sized (< 5 bps) rather than a semantic inversion — so a future row crossing by percent reads as the different, worse finding it would be.

transport.get_best_bid_ask gains the warning, including that translate.to_price_side’s BUY→ask / SELL→bid mapping inverts on a crossed row (both directions optimistic — the one outcome that mapping exists to prevent). Its field list also claimed rows carry symbol, bid and ask while omitting timestamp, which #217 F8 had already established; corrected.

The README’s “the five READ fixtures match observed responses” gains the #413 caveat next to the #230 one.

Scope

No behaviour change — nothing calls this endpoint today. Deciding the contract (normalise vs. declare it unusable for pricing) stays on #413, so a first consumer cannot pick silently.

Full suite: 3556 passed, 3 skipped. ruff check and mypy clean.

fix(scripts): make the robinhood credential guard catch the error it claimed to (#416)

The guard didn’t do what its comment said

scripts/robinhood_smoke.py:

A raw Ed25519 seed is 32 bytes, which is 44 base64 characters with padding. Checking this before the first request turns the most likely operator error — pasting the public key, or a PEM, or a hex string — into a precise message instead of a 401.

It cannot. A raw Ed25519 public key is also 32 bytes, so it is also 44 base64 characters, from the same alphabet. Length, alphabet and decoded size are all identical. The check never covered the case it named.

On 2026-08-19 an operator pasted the public key into ROBINHOOD_API_KEY. Every guard passed, every request signed correctly, and the venue returned 401 — indistinguishable from a revoked key, a stale clock, or a signing bug. Telling those apart cost an afternoon.

What actually separates the two values

One is derived from the other. So the guard derives it:

ROBINHOOD_API_KEY holds the base64 PUBLIC key of ROBINHOOD_PRIVATE_KEY, not an API key.

The public key is what you paste INTO Robinhood's credential page. What belongs here is the
identifier Robinhood issues back once the credential exists (rh-api-<uuid>).
Every request would sign correctly and be rejected 401, because the venue has no record of
this key.

Fix: sign in to web classic, open https://robinhood.com/account/crypto, choose Add key, paste
the value currently in ROBINHOOD_API_KEY as the public key, tick the API actions this
credential needs, and put the identifier it returns here. ROBINHOOD_PRIVATE_KEY stays as it
is -- the keypair is already correct.

That is the verbatim output of the new guard run against the real .env that produced the incident.

Two checks, most-specific first:

  1. ROBINHOOD_API_KEY equals the public key of ROBINHOOD_PRIVATE_KEY — named outright, with the fix, because this one needs no round trip to diagnose: the value in the wrong variable is exactly what the credential page wants.
  2. ROBINHOOD_API_KEY is any 32-byte base64 value — an Ed25519 key is never an API key identifier whatever the identifier format turns out to be, so this gates even with nothing to match against.
Two details that are load-bearing

validate=True on the decode. Without it b64decode discards out-of-alphabet characters rather than refusing, so a genuine rh-api-<uuid> — which contains - — could decode to 32 bytes by accident and be rejected as a pasted key. Pinned by test_a_real_api_key_is_not_mistaken_for_base64.

The rh-api-<uuid> shape is quoted, not enforced. It is an observation about one credential, not a documented contract; a hard gate would reject a valid key the day Robinhood changes the format. The two checks that do gate are the ones that cannot be wrong.

_public_key_b64 returns None rather than raising on a malformed seed or a missing pynacl — a diagnostic that crashes on the malformed input it exists to describe is worse than one that stays quiet, and the length check above already covers that path.

The length check stays (it still catches a PEM, a hex string, a truncated paste) with its overreaching claim removed and a note saying which case it does not cover.

Also

…truncated — full description in #416.

feat(update): self-update — pull, deploy into the launch folder, relaunch (#415) (#417)

What

The console keeps the deployment current (#415): keel update (CLI) and the TUI Account menu’s update entry — one service, two front-ends, fail-safe by construction. keel/commands/update.py is pure orchestration over subprocesses/HTTP; both front-ends render the same plan (render_plan_lines), run the same run (run_update), and ask the same typed gate (typed_update_gate — the CLI’s own _require_interactive_confirmation, failing closed off a TTY).

  • latest_release(fetch=...) — the public GitHub releases API, no auth, no secrets; honest errors for network, rate-limit and unexpected payloads.
  • plan_update(release, ...) — current vs latest (semver; a release supersedes its own pre-release, junk tags refuse rather than guess), the four production wheel assets, the Release/ dir under the launch folder, every keel*.db to back up, the RUNNING venv, and offered/refused with a reason for every refusal.
  • run_update(plan, *, echo, confirm_gate, download/install/migrate/verify=...) — the runbook’s manual procedure, in its order, each step streamed, each failure honest.
  • relaunch_tui(venv, argv) — the execv closure (pure construction; the new venv’s keel entry carrying the original TUI argv, falling back to [keel, tui]).
The fail-safe contract
  • The gate runs inside the service — run_update takes confirm_gate and calls it before any mutation: there is no ungated path to the writes. Never automatic, always typed, fails closed off a TTY.
  • Backups first — every keel*.db in the launch folder is copied to <db>.bak-before-<version>-<ts> before anything downloads or installs (pinned by a test that asserts the backups exist from inside the install seam), and backups are never deleted.
  • The four production wheels only — keel_core, keel_broker_api, keel_broker_coinbase, keel_trader by exact name, never Release/*.whl: the fake and Robinhood wheels can never ride along (RELEASING.md’s rule).
  • Install by path into the RUNNING venv via uv (uv is a deployment dependency of self-update; an absent uv is an honest error naming the manual procedure), then keel migrate --db each database with the new build.
  • Verify before cleanup — the new venv’s keel versions must report every keel distribution at the target; only a verified success removes the superseded wheels.
  • A failed verify is loud, never papered over — pip has already replaced the packages, so the updater says exactly that state, re-installs the previous wheels best-effort while they remain in Release/ (they do — cleanup only happens on success), and names the manual recovery.
Refusals and the relaunch split

Dev/source checkouts are refused (not a release build; nothing installed; or the running keel package resolving from the launch folder itself — the uv run keel case). The TUI relaunches itself on a verified success (os.execv the new build’s entry, terminal restored first, original TUI argv carried); the CLI prints the command and does not relaunch anything.

The audit surfaces

…truncated — full description in #417.

feat(brokers): report robinhood’s own sizing bounds on the preview (#418)

First half of #410. Reported, never enforced — see the boundary section below for why that split is the design and not a shortcut.

The gap

trading_pairs carries min_order_amount, asset_increment and max_order_size, and nothing read them — get_trading_pairs was one of two transport methods the adapter never called. A sub-minimum or off-increment order found out at the venue.

preview_order now reads them, reports what they say through Preview.errors, and puts the bounds themselves in Preview.detail, so the human at the confirm gate sees the number an order was measured against rather than a bare sentence.

The denominations are not the same

This is the part that bites, established live on 2026-08-19 across all 89 pairs:

symbolpricemin_order_amountas QUOTEas BASE
BTC-USD68,367.130.1$0.10$6,836.71
ETH-USD2,098.510.1$0.10$209.85
DOGE-USD0.0728860.1$0.10$0.01

All 63 pairs that carry the field report the same 0.1. A constant cannot be a base-denominated minimum across six orders of magnitude of unit price — it is a venue-wide $0.10 floor in quote currency.

The obvious implementation, base_size >= min_order_amount, would reject every BTC order under 0.1 BTC (~$6,800), the exit path included — a far worse failure than the missing check this issue exists to add.

max_order_size and asset_increment go the other way, both base: max_order_size varies per asset (20 BTC, 6,500,000 DOGE) and only lands on a comparable notional ceiling ($1.37M, $474k) read that way. The names carry it once pointed out — amount is quote, size is base — which is why _PairRules says so at the top and detail labels each key with its denomination.

The boundary: reported, never enforced

place_order does not call this, and test_place_order_does_not_consult_the_sizing_bounds pins that it does not. Two reasons, and the second decides it:

  1. Every order this adapter can place is an exit or a protective leg — entries are MarketIOCByQuote, which this venue cannot express at all. A check that refuses on those paths can strand a position or leave one running without its stop.
  2. Nobody has ever watched this venue reject an out-of-bounds order, because no order has ever been placed against it (#412). Refusing locally would be a guess, and a guess that refuses an order the venue would have accepted is a new failure this package invented.

The off-increment note says outright that the outcome is unobserved rather than predicting one. Enforcement can be revisited once #412 has seen a real rejection.

Absences stay absences

Three cases that must not collapse into a passing check, one test each:

  • a pair with no min_order_amount (26 of 89, #230) → says the minimum was not checked, detail reads unknown, not 0
  • a trading_pairs call that fails or returns nothing → says the bounds were not checked
  • an unpriced market order → not accused of being below a minimum; its zero quote_size is an absence, not a number

symbol= is passed so the venue filters. #230 is the standing lesson: the probe that read results[0] off the unfiltered 89-row response got BILL-USD and concluded the venue publishes no minimum at all.

…truncated — full description in #418.

feat(brokers): give the port an idempotency key so a placement retry is safe (#419)

Closes #409.

The defect

Every adapter minted a fresh uuid4 client_order_id per place_order call, so no retry was ever deduplicated. A caller retrying after a timeout — exactly when the first request may already have reached the venue — placed a second live order, because the retry carried a different id and the venue had nothing to match it against.

Robinhood and Alpaca documented the hazard. Coinbase’s docstring asserted the opposite:

Place a live order. A fresh client_order_id per call gives Coinbase idempotency.

That had it backwards. A per-attempt id is precisely what withholds idempotency — it leaves the venue nothing to deduplicate on.

Why it stayed a comment

The port could not express the difference. place_order(spec) cannot tell a retry of one intent from two orders a strategy genuinely meant to place. So the port now carries it:

def place_order(self, spec: OrderSpec, *, idempotency_key: str | None = None) -> PlaceResult
  • None mints per attempt — the unchanged default. The opposite default is not safer: an id derived from the order would collapse two deliberate orders into one, and a position at half the intended size is as wrong as one at twice it.
  • A key resolves every attempt under it to one venue-facing id.
The derivation, and why it hashes

keel_broker_api.port.resolve_client_order_id owns it, and turns the key into a uuid5 rather than passing it through:

resolve_client_order_id(None)                      # -> a fresh uuid4, one per attempt
resolve_client_order_id("cycle-7:pos-3:exit")      # -> the same uuid5, every time

The venues do not agree on what a client order id may be — Robinhood’s is a UUID, Alpaca’s a string of up to 128 characters. Hashing lands on the intersection, so a caller uses whatever natural key it has (a cycle id, a position id, a leg name) without knowing which venue the order will be routed to. One derivation in one place: an adapter inventing its own rule would make the same key mean different orders at different venues.

uuid5 is deterministic across processes, which is the case that matters. A retry issued by a new process — what a crash produces — derives the same id as the attempt that crashed. An in-memory table of “ids I already sent” cannot cover that.

The namespace is pinned by a test that writes the expected UUID out rather than recomputing it from the implementation. Recomputing would pass just as happily if the namespace changed, and a changed namespace silently makes every previously-derived id unreachable — which is the deduplication this mechanism exists to provide.

Threading
adapter
robinhoodkey → to_order_body(client_order_id=...)
coinbasekey → create_order(client_order_id=...), backwards docstring corrected
alpacakey → to_order_body(client_order_id=...)
fakeaccepts and deliberately ignores — it has no client_order_id to carry it into, and a dedup table there would make the stand-in behave better than the venues it stands in for
Conformance

…truncated — full description in #419.

feat(brokers): retry robinhood GETs on 429 and 5xx, never POSTs (#420)

Closes #411. Unblocked by #419 — see the POST section.

The gap

Robinhood allows 100 req/min sustained, 300 burst, and the transport had no backoff at all: a 429 propagated as a bare HTTPError alongside 401 and 5xx.

The limit is reachable in ordinary use, not just under abuse. get_fee_summary alone can spend 21 requests in one call (_MAX_PAGES + the account read), and preview_order now adds a trading_pairs read on top (#418). A “wait half a second” was turning into a failed cycle.

What retries
GET, 429 / 500 / 502 / 503 / 504retried, up to 4 attempts, 0.5s doubling, capped at 8s
GET, 404not retried — it is an answer, and the 404→None split stays as load-bearing as ever
GET, 401not retried — a signature or clock problem does not heal in half a second; retrying turns one clear failure into four identical ones
POST, anythingnever retried — see below

Retry-After wins where the venue sends one, since it knows when its window resets and we do not — but it is clamped, not obeyed. Retry-After: 3600 is a legal response, and honouring it literally would park a trading loop for an hour inside what the caller believes is a bounded read.

Only the delta-seconds form is read. The HTTP-date form is legal and deliberately ignored: honouring it means trusting the server’s clock against ours, and a skewed clock gives either an instant retry (useless) or a very long sleep (worse), while exponential backoff is already correct without any clock at all.

A retryable status that survives every attempt still raises, so a persistent quota problem stays visible rather than becoming a hang.

Why a POST is never retried

This is the decision, not the unfinished part.

A 429 or 5xx on create_order is an unknown outcome, not a refusal — the venue may have accepted the order before the response was lost. Sending it again places a second live order unless both attempts carry the same client_order_id, and this layer cannot tell whether they would: the body arrives already built, and an id derived from a caller’s idempotency key (#409) is indistinguishable from a freshly minted uuid4 from here.

Now that #409 exists, a caller can make a placement retry safe. So the retry belongs where that knowledge lives — above the adapter, not inside the transport. A transport that retried POSTs would be guessing, on the one request where guessing costs money.

test_a_POST_IS_NEVER_RETRIED_even_on_a_429 asserts one call and zero sleeps, with the reason in the docstring.

Signing moved inside the loop

The signature is valid for 30 seconds from its timestamp. A retry that waited out a backoff and presented the first attempt’s signature would come back 401 — a failure invented by retrying, and indistinguishable from a bad credential.

Pinned by a test that advances the clock between attempts, because two attempts in the same wall-clock second sign identically and would prove nothing.

Testability

sleep is injected, and _transport() in the tests defaults it to a no-op. Without that, every existing test touching a 429 or 5xx would have waited out real backoff — the first run of this change took 11.8s instead of 1.3s for exactly that reason.

…truncated — full description in #420.

feat(scripts): a fenced one-order probe for the shapes only a real order reveals (#421)

Refs #412. The instrument, not the observation — #412 stays blocked on a trade-scoped credential.

Why

robinhood_smoke.py closes everything a GET can close. What it cannot reach is the half of the adapter that only exists once an order does: the placement response’s state values, the per-order fee_charged that get_fee_summary sums, whether a cancel 200 actually confirms, and every field name in the four order fixtures — all transcribed from documentation, none corroborated by a live response.

No sandbox exists, and the conformance suite must never see live credentials. One real order is the only way, so this is the smallest and most heavily fenced version of it: a limit buy far below the bid, polled, then cancelled.

The fences
  • Refuses without --place. Default prints the exact body and stops.
  • Exactly one order-creating POST per process, enforced at the request layer. _OneOrderOnly rebinds _request rather than wrapping — a __getattr__ wrapper is bypassed by the transport’s own internal calls, the trap robinhood_smoke._ReadOnly documents.
  • Refuses any discount under 25% of the venue’s own best bid. 25 and not 1 because best_bid_ask’s legs are sampled independently and cross by up to ~1.4 bps on tight pairs (#413) — the fence has to survive a quote that is slightly wrong either way.
  • Refuses a notional over --max-notional (default $10), so the amount at risk is bounded by a number the operator typed.
  • Checks the venue’s own bounds first — below min_order_amount (quote-denominated, #410) or off asset_increment, the venue would reject and the run would learn nothing.
  • Cancels in a finally. An exception between placement and cancellation must not leave a live resting order behind; this process holds the only handle on it.
  • Passes an idempotency_key (#409), so a re-run after an ambiguous failure is one order at the venue.
A failed placement is classified, not raised
400 / 401 / 403 / 404 / 422the venue refused this request — certainly no order
anything else (5xx, timeout, dropped connection)UNKNOWN — silence is not refusal

The unknown branch names the client_order_id and says to go and look, because that id is the only handle on an order this process never saw. Reporting silence as “nothing happened” is how a probe leaves a live order behind and tells the operator it did not.

A bug the tests caught

Recorded responses must preserve quoting — Decimal (arrived unquoted) back as a number, str (arrived quoted) still a string — because this venue is inconsistent about it in the same object (#217 F6).

The obvious implementation is silently wrong: json renders floats with float.__repr__, not repr(o), so a float subclass carrying the original digits is bypassed and Decimal("0.00000001") is written 1e-08. That is the malformed-number hazard translate._render exists to prevent — baked into a fixture, which is exactly the class of wrong claim #414 and #230 were. Digits now go out as a sentinel string and are unquoted textually, with a guard refusing to rewrite a payload that already contained the sentinel. Both pinned by test.

Run against the live venue today

Twice. Both returned:

403 {"type":"client_error","errors":[{"detail":"You do not have permission to perform this action.","attr":null}]}

…truncated — full description in #421.

chore(release): 0.10.0 (#422)

Step 1 of docs/RELEASING.md: the version bump, in a reviewed PR, because the release workflow refuses to change the version itself.

Why minor and not patch

Three things since v0.9.3 change what an implementor or a deployment can rely on:

  • A new distribution. keel-broker-alpaca (#382, #384) and the paper-equities profile that selects it (#386) — a deployment can now be US equities via the broker port, not crypto only.
  • The port contract moved twice. market_clock/market_schedule made venues session-aware (#385); place_order gained idempotency_key (#419). Both carry defaults so no caller breaks, but a third-party adapter that doesn’t accept them is no longer a Broker — the conformance suite now says so. A patch bump must not hide that.
  • The operator console. The TUI became keel’s console across #399–#408, and keel update (#415/#417) makes a deployment self-updating.
Every pin moves with it

All seven distributions, and the == cross-pins:

keel-trader        0.9.3 -> 0.10.0   (+ 3 pinned deps)
keel-core          0.9.3 -> 0.10.0
keel-broker-api    0.9.3 -> 0.10.0   (+ 1)
keel-broker-coinbase                 (+ 2)
keel-broker-alpaca                   (+ 2)
keel-broker-robinhood                (+ 2)
keel-broker-fake                     (+ 2)

The four production distributions are required == at the same version (RELEASING.md, “Release assets”), so a bump that missed one installs a mixed set — the keel-trader 0.5.7 against keel-core 0.5.5 failure keel versions exists to catch, and which ~/keel actually ran across two releases. No 0.9.3 remains anywhere in the workspace.

Verified

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

After merge

Actions → Release → Run workflow, entering 0.10.0. The workflow validates the input against pyproject.toml, runs tests + ruff, stamps the commit, builds all packages, installs into a clean venv by path and asserts a clean [release] identity, verifies the live config asset is mode: confirm, tags v0.10.0, and publishes.

One stale doc, not fixed here

RELEASING.md’s asset table predates keel-broker-alpaca — it lists six distributions and describes the fake and Robinhood wheels as published-but-not-deployed, with no row for Alpaca. uv build --all-packages will build and publish it regardless, and keel versions discovers distributions from installed metadata rather than a hardcoded list, so nothing is functionally wrong. Worth a follow-up rather than scope in a release bump — particularly since keel update downloads exactly four production wheels, which means the equities profile is not self-updatable today.

chore(release): update uv.lock to 0.10.0 (#423)

Fixes the failed 0.10.0 release run: https://github.com/CodeGateSoftware/keel/actions/runs/32316126013

What happened

#422 bumped the version in all seven pyproject.toml files and missed uv.lock, which is tracked and still pinned every workspace member at 0.9.3.

The workflow’s “Sync dependencies” step regenerated it, which left the checkout dirty. “Stamp build info” then stamped a build from that dirty checkout, and keel --version reported:

warning: this build is NOT reproducible -- it does not correspond to a commit. Do not run it against live funds.
keel 0.10.0+29464f06ee11 (DIRTY) [release]
::error::artifact reports a dirty tree

The guard did exactly its job. version.build_info() re-checks git status --porcelain against the stamp rather than trusting it — the “believe git over a stale stamp” branch — and a (DIRTY) build corresponds to no commit, which RELEASING.md says must never run against live funds. Lint, Type-check, Test and Build all passed; nothing was tagged and nothing was published.

The fix

uv lock. Seven lines, one per workspace member:

Updated keel-broker-alpaca    v0.9.3 -> v0.10.0
Updated keel-broker-api       v0.9.3 -> v0.10.0
Updated keel-broker-coinbase  v0.9.3 -> v0.10.0
Updated keel-broker-fake      v0.9.3 -> v0.10.0
Updated keel-broker-robinhood v0.9.3 -> v0.10.0
Updated keel-core             v0.9.3 -> v0.10.0
Updated keel-trader           v0.9.3 -> v0.10.0

No third-party dependency resolution moved — the diff is 7 insertions, 7 deletions.

Verified by reproducing the CI condition

With the lock committed, uv sync now leaves the working tree clean. That is the precise condition that failed: the release stamps from a checkout that has just been synced, so uv sync leaving anything modified is what makes the artifact unreleasable.

Follow-up worth considering

The lockfile is only checked implicitly, by the dirty-tree guard three steps later, and the error it produces (“artifact reports a dirty tree”) does not name the cause. A uv lock --check immediately after checkout would fail in seconds with “the lockfile is out of date” instead of failing after Build with a message that sends you looking at the stamp. Happy to open that as a separate PR.

v0.9.3

Voir cette version sur GitHub ↗

Built from 492382043d4f. Version binds to this hash: keel --version reports keel 0.9.3+492382043d4f [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.9.3-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

docs(readme): the evidence-cadence story — ~940 signals/yr on the hourly profile (#362)

Brings the README up to date with this week’s news (discussion #359):

  • New paragraph after the honest-result block: the cadence problem (100-trade floor at 2.15 signals/asset-yr = 31–84 years — “waiting is not a slower path; it is no path”) and its solution — the same rules on ONE_HOUR bars (49.4/asset-yr, median n=268) running on the 19-asset paper-hourly profile behind the measured 15-minute health screen, ~940 signals/yr pooled, n=100 pooled review weeks away (#353, 2026-09-30). The hourly configuration’s own net-negative caveat is stated as plainly as the daily one. Links: the hourly experiment, the announcement, the runbook, #353.
  • ~2,800 tests → ~3,000 (suite is 2,967 passed + 1 skipped on main).
  • The runbook documentation-map line now names the three deployment profiles (daily paper, live, hourly evidence) instead of “two accounts”.

Docs-only; no code touched.

perf(pullback): O(1)-per-bar running state replaces the full-series recompute (#352) (#363)

What

PullbackContinuation.detect()/exit_signal() are called once per bar by strategy.backtest, each time with the whole prefix. Every full-series read the rule made — regime.detect_phase (a pivot scan of the entire prefix), indicators.ema_fan, indicators.atr, levels.swing_highs/swing_lows — consumed only its last value, so the backtest was O(n²) in bars. This replaces the per-bar recompute with a _RunningState that extends bar-by-bar in O(1).

Measured (deterministic synthetic hourly fixtures, shipped defaults, market-fill model)
fixturepre-fix (f7a0cdf)post-fix
1y — 8,784 bars8.9s (8.2s in the original #352-era measurement)0.2s
5y — 43,800 bars168.6s (233.5s original; the real-data 5y run that opened #352 was killed at 38+ CPU-minutes)2.6s

18.9x (this repro) / 28.3x (original) runtime ratio for a 5.0x bar count — squarely quadratic. cProfile put 71% of the 1y run in regime.detect_phase’s _swing_highs/_swing_lows and 26% in indicators.ema_fan. The residual 13x-vs-5x superlinearity post-fix is the engine’s per-bar candles[: i + 1] slice (shared by every rule, C-level memcpy), not the rule’s math.

Why not the turtle rule’s tail-slice

TurtleBreakout solved the same shape by deciding on a bounded tail of history — acceptable there because Donchian is exact over its own window and ADX/ATR converge. Not here: EMA(50) seeded four bars back is a different number than EMA(50) seeded five years back, so a tail would change which setups fire and silently re-parameterize the rule.

The bit-identical contract

_RunningState.extend() performs the SAME floating-point operations, in the same order, as the pure functions applied to the whole prefix: indicators.ema’s alpha*v + (1-alpha)*prev, indicators.atr’s seed-mean then Wilder step (true-range expression copied verbatim), regime._swing_highs’ radius-1 and levels.swing_highs’ radius-2 strictly-beyond-neighbour pivots (extend confirms exactly the one candidate pivot the new bar completes, so it can never miss one a rescan would find), and detect_phase’s exact reads/comparisons. Cache validation is length + first_ts + last_ts; any call that does not strictly extend the cached prefix rebuilds from the pure functions (cold start, slid/shrunk window, unrelated series). _sync() runs at the top of both detect() and exit_signal() before any gate can decline.

regime.detect_condition(candles) stays a direct call: it reads only the last lookback=20 bars, so it is O(1)-bounded per bar already — state-ifying it would change nothing.

How the contract is pinned (tests/strategy/test_pullback.py)
  • bar-by-bar equivalence — every prefix of two deterministic series (one tie-heavy on purpose, to exercise the pivot predicates’ strictness on equal neighbours): each EMA tail, atr_last, both phase pivots, both target pivots compared float/Decimal-exact (==, not approx) against the pure recompute, plus _phase/_tail_aligned against detect_phase/fan_aligned.
  • rebuild-vs-extend — the same final prefix reached by one-bar steps, a gappy walk, and a cold start lands on the identical state, equal to a full _RunningState.build.
  • golden backtest — three deterministic windows (defaults; ATR stop + swing target; band zone + fib target — every state-read path flows into trade prices) captured by running the pre-fix implementation at f7a0cdf on these exact fixtures; the new code reproduces the full trades list and every metric exactly (Decimal-string compare — one ulp fails it). The golden is a capture, not a regenerable baseline; the docstring says so.
  • timing smoke — the 1y 8,784-bar window completes < 60s (lenient for CI variance; it runs in ~0.2s). No perf harness beyond this one test, per the issue.
Gates
  • uv run pytest -q — 2973 passed, 1 skipped (pre-existing). The existing pullback suite passes unchanged; no assertion was touched.
  • uv run ruff check keel tests packages — clean.
  • uv run mypy — clean (238 files).

Fixes #352

chore(release): 0.9.3 (#364)

Version bump across the six pyprojects including the ==-pinned sibling dependencies.

Ships since 0.9.2:

  • #352 / #363 — PullbackContinuation backtest performance: the quadratic per-bar full-series recompute (measured 28.3x ratio on 5x bars; 71% in the phase-pivot scans, 26% in the EMA fan) replaced by _RunningState running values extended bar-by-bar with the pure functions’ exact arithmetic — bit-identical results (independent golden reproduction + ~31k-case fuzz; review majors on cache validation and lazy acquisition fixed and re-measured pre-merge). 1y hourly 8.9s → 0.19s; 5y 168.6s+ → 2.59s; identical trades.
  • #362 — README: the evidence-cadence story and three-profile runbook line.

Packaging pins verified locally: uv run pytest -q tests/test_packaging.py → 15 passed.

v0.9.2

Voir cette version sur GitHub ↗

Built from f7a0cdf721a0. Version binds to this hash: keel --version reports keel 0.9.2+f7a0cdf721a0 [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.9.2-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(paper-hourly): expand the universe 8->19, Tier-2 capped at 2% target weight (#358)

Refs #351.

What

config.paper-hourly.yaml’s universe grows from 8 to 19 assets. The 11 Tier-2 additions — ZEC NEAR AVAX UNI FET ICP DOT CRV ALGO BCH DOGE — each passed a 15-minute data-health screen on 2026-08-17 (coverage >= 95.98%, zero zero-volume bars over 90 days; results recorded in the issue).

Why a superset, not a mirror

The hourly profile’s job is to collect promotion-gate evidence fast; more health-screened assets means more rows per hour. Paperforward (the daily profile) deliberately stays at 8 so its evidence remains a like-for-like 8-asset series — hourly is now a strict superset of paperforward’s universe. The universe test was rewritten (not deleted) to pin exactly that, with the rationale in its docstring.

Sizing: the 78/22 split
  • Incumbents keep their relative shape scaled to 78% total: BTC .230, ETH/PAXG .155, SOL/XLM/LTC/ADA/LINK .048 each.
  • Each Tier-2 addition gets a flat 2% (11 x 0.020 = 0.220). Total exactly 1.000.
  • The 2% Tier-2 cap is a deliberate guardrail: the sizing half of the spread protection whose live-path half is #350’s spread gate.
  • Incumbent rules/params are unchanged, so their evidence stays comparable across the expansion; only their paper sizing rescales 1.00 -> 0.78 total.
Also
  • market_data: comment recomputed: 19 products x 2 series = ~38 requests per cycle (57 in the UTC-boundary hour); header’s “same universe as paperforward” claim updated to “superset”.
  • Runbook: the bootstrap loop seeds all 19 tickers, and a new paragraph records the 2026-08-17 expansion (health-screen gate, 78/22 weighting, paperforward staying at 8).
  • Net-negative caveat and cadence mechanics prose untouched.
Gates
  • uv run pytest -q tests/test_paper_hourly_profile.py — 15 passed
  • uv run pytest -q — 2951 passed, 1 skipped
  • uv run ruff check keel tests packages — clean
  • uv run mypy — clean (238 files)
  • Config loads: 19 1.000

Note: #351 also covers the operational seeding/warm/verify steps that happen after this PR merges (bootstrap commands are in the runbook section this PR updates).

feat(executor): routing-time max-spread gate for live BUY entries (#360)

Fixes #350.

What

A live BUY whose previewed book shows (best_ask − best_bid) / mid at or beyond execution.max_entry_spread_pct (new execution: config section, default 0.005 = 50bp) is refused after the preview and before the confirm gate and placement — so a thin book cannot be entered at a moment its spread alone makes the fill economics materially worse than the cost model assumes. The refusal is recorded in ExecutionResult.vetoed_by (tokens max_entry_spread / book_unreadable, the same one-legible-token shape rail violations use) and logged at WARNING as a structured event (executor.entry_spread_refused with the measured spread, threshold and product; executor.entry_book_unreadable for the fail-closed arm).

This is the live-path half of the spread guardrail whose sizing half (#358) caps every Tier-2 addition at a 2% target weight.

Design decisions
  • Post-preview placement, deliberately. guards.check is broker-less by design, and the book exists only in the broker.preview_order result — so this is a routing-time gate beside the eighteen rails, not a numbered guards.check rail (no renumbering anywhere). It consumes the same preview #332’s _warn_if_market_routing_overrides_entry reads: one helper (_preview_book — bid/ask read independently, missing-keys/NaN/non-finite/non-positive-safe), two consumers. The #332 warning keeps its exact behavior and position; the gate runs after it, before confirm/place.
  • BUY-only. Exits, exit brackets, stop rolls and scale-outs are never gated — the same principle that makes rail 17 halt entries, not exits. A spread gate that trapped an exit would strand a position in exactly the book conditions the rule said to leave.
  • Paper mode never runs the gate. _paper_enter fills synthetically without a preview, so the paper-hourly profile accrues no evidence about this gate — a reason it ships before any live resumption, not before paper expansion.
  • Fail-closed on an unreadable book. A live BUY whose preview carries no readable bid AND ask (missing keys, NaN, non-finite, non-positive, or spread arithmetic that overflows — the #336 extreme-exponent hazard, refused here rather than swallowed as telemetry) is refused with the distinct book_unreadable reason. “Cannot know” is a different fact from “too wide”. Verified the real venue’s preview carries both sides: cb_client.preview_order maps best_bid/best_ask to Decimal (tests/fixtures/cb_preview_order.json), and the port’s Preview.detail carries them as strings.
  • 50bp default anchored to #334’s SLIPPAGE_CAP_PCT: the backtest never assumes more than 50bp per-leg slippage on even the thinnest book, so a spread AT the cap has consumed the model’s entire worst-case cost estimate and the taker fee rides outside it — hence the boundary is >= (fail-closed), unlike #332’s strictly-greater visibility threshold. Validated on load to (0, 0.10]; violations raise ConfigError naming execution.max_entry_spread_pct.
Test-side consequence (honestly reported)

The shared test fakes’ bookless default previews modelled a shape the real venue does not return, and under a fail-closed gate every “normal successful BUY” test through them would refuse. The fakes (tests/execution/test_executor.py, tests/test_agent.py, tests/test_cli.py, tests/execution/test_reconcile.py) now carry both book sides, and the one #332 test that borrowed the default preview as its degraded/bookless shape (test_a_preview_without_a_book_quote_is_silent_not_fatal) constructs that shape explicitly — meaning unchanged, sourcing changed. The golden config fixtures were regenerated via the documented script; the defaults golden now pins execution.max_entry_spread_pct = 0.005.

Docs
  • docs/operator-runbook.md: the Tier-2 guardrail story — sizing half (#358’s 2% cap) + live-path half (this gate), and why paper accrues no evidence about it.
  • README.md + docs/fiqh-basis.md: the gate described beside the rails enumeration as exactly what it is (BUY-only, live-only, fail-closed); the “eighteen rails” count is unchanged because this is not a numbered rail.
  • Shipped config templates (keel/templates/config.yaml + repo config.yaml, config.live.yaml) document the knob.
Gates
  • uv run pytest -q — 2965 passed, 1 skipped
  • uv run ruff check keel tests packages — clean
  • uv run mypy — clean

chore(release): 0.9.2 (#361)

Version bump across the six pyprojects including the ==-pinned sibling dependencies (the 0.9.1 bump missed those first — not repeating that).

Ships since 0.9.1: #350 / #360 — the routing-time max-spread gate for live BUY entries (execution.max_entry_spread_pct, default 50bp anchored to #334’s SLIPPAGE_CAP_PCT; BUY-only, live-path-only, fail-closed on an unreadable book with a distinct reason; one _preview_book helper shared with #332’s warning). Reviewed adversarially (approved, zero blockers) with two mutation-verified hardening tests applied pre-merge. Paper profiles are structurally exempt — no behavioral change for the running hourly evidence pipeline.

Packaging pins verified locally: uv run pytest -q tests/test_packaging.py → 15 passed.

v0.9.1

Voir cette version sur GitHub ↗

Built from 1be07f208d40. Version binds to this hash: keel --version reports keel 0.9.1+1be07f208d40 [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.9.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

docs(experiments): the honest-cost restatement and the DCA dip-bonus ablation (#339) (#347)

What & why

Closes #339’s measurement checkboxes. Two firsts, one date:

The honest-cost simulate re-run — the first keel simulate under per-product slippage (#334, v0.9.0): verdict TRAIN MORE, unchanged, and the correction runs exactly the conservative direction — BTC identical (floor-anchored), XLM 4.7567→4.4435, ADA 1.7638→1.7087, PAXG capped at 50bp. No flattered outlier survived; the TON-class 3.751-on-n=9 result can no longer print at thin-book pricing. This is the measurement that decides #335: the gate opt-in stays precautionary (nothing promotes on thin assets today), with the evidence now on record.

The DCA dip-bonus ablation — first-ever coverage of the only unswept rule family, three cells (dip_bonus_pct 0/1/2) through sim/portfolio_sim at keel’s real costs over ~5 years of BTC hourly. The pre-registered ordinal expectation was NOT confirmed: cells within 0.65% relative, no monotone direction (1.40742 / 1.40424 / 1.41325). The honest reading is stated: mechanism mismatch (the citation holds cash until threshold dips; the param always buys and scales size) plus single-asset/single-window power. No cell preferred; the live rule stays at 0.

Both trials appended to the hash-chained ledger (chain verified clean). Also records the deployment actions: DCA twins re-enabled via keel rules enable + promote --force (the documented bypass, warnings logged), so the paper pipeline now has an always-firing family. v0.9.0 was cut and deployed first (PR #346; four distributions verified, keel versions clean, both DBs at schema 10).

Tests-first evidence

Measurement PR: the instruments’ outputs are quoted verbatim; the ledger rows were appended through keel.research.ledger.append_trial with verify_chain returning []. Gates: 2947 passed / 1 skipped; ruff clean; mypy clean.

Gates
  • uv run ruff check keel tests packages — All checks passed!
  • uv run mypy — Success: no issues found in 238 source files
  • uv run pytest -q — 2947 passed, 1 skipped
Scope check
  • Documentation, experiment records, and ledger rows only — no code, rails, rules, or classifications touched.

fix(data): keel fetch honors config.market_data.granularities (#354)

Bug (#349)

keel fetch hardcoded the module constant _SIM_GRANULARITIES = [ONE_HOUR, ONE_DAY] everywhere in its flow, so it ignored config.market_data.granularities. Every shipped config lists ONE_DAY/ONE_HOUR/FIFTEEN_MINUTE, which meant the runbook’s documented warm step keel fetch never warmed the FIFTEEN_MINUTE confirmation series: a fresh deployment’s cache had zero 15-minute candles, and the agent’s first cycles inherited a full multi-hundred-request catch-up. keel agent and keel monitor already did the right thing (list(config.market_data.granularities)); fetch was the odd one out.

Fix

The fetch flow in keel/cli.py now computes granularities = list(config.market_data.granularities) once and threads it through all four places that previously hardcoded the simulate pair:

  • _assess_products takes the granularities as an explicit parameter (both call sites updated);
  • the --check --fail-on-gaps unexplained count;
  • ensure_history(...) — the actual warm fetch, core of the bug;
  • the --repair-gaps loop.

Also fixed: the TUI’s _do_fetch (keel/commands/tui.py) — the dashboard twin of keel fetch, documented as such (“matches keel fetch --years’s own default”) — had the identical bug and now uses the same config-driven list.

simulate deliberately KEEPS _SIM_GRANULARITIES ([ONE_HOUR, ONE_DAY]): those are the backtest engine’s supported timeframes — an engine limit, not a data choice. A short comment where the fetch list is computed explains the asymmetry, and a new pin test (test_simulate_still_fetches_exactly_the_engine_timeframes) locks it.

Tests (red first)

New tests in tests/data/test_fetch_cli.py, all confirmed RED against the pre-fix code for the right reasons, then green:

  • test_fetch_warms_every_configured_granularities — before the fix: Extra items in the right set: FIFTEEN_MINUTE (fetch requested only ONE_DAY/ONE_HOUR under a three-granularity config);
  • test_check_reports_freshness_for_every_configured_granularity — before: 'FIFTEEN_MINUTE' not in output;
  • test_fetch_touches_only_the_configured_granularities — before: Extra items in the left set: ONE_HOUR (config listed only ONE_DAY; fetch ignored it).

Existing tests updated to match the intended behavior change (they seed a three-granularity config, so their fixtures now seed the 15-minute series too; the repair pass count goes 6 -> 9 = 3 products x 3 granularities). Full suite green.

Behavior change for existing deployments (intentional, benign)

Scheduled and manual keel fetch runs now also ensure FIFTEEN_MINUTE history. The agent already polls 15-minute candles every cycle, so the cache receives them anyway — fetch just makes the documented warm step honest. --years (default 5) applies to all granularities uniformly; a venue lacking deep 15-minute history lands in the already-existing “some series are still short … usually benign” branch — no new handling needed.

Docs
  • docs/operations/scheduled-fetch.md: notes that fetch warms exactly market_data.granularities (same series agent/monitor poll) and that simulate is the deliberate exception.
  • docs/operator-runbook.md: the paper-hourly bootstrap warm-step comment now states the config is the source of the granularity list.
Gates
  • uv run pytest -q — 2951 passed, 1 skipped
  • uv run ruff check keel tests packages — all checks passed
  • uv run mypy — no issues in 238 source files

Fixes #349

test(data): collapse repeated fetch fixture seeding into helpers (#355)

Dedupes the three-granularity seeding block repeated across six fetch tests in tests/data/test_fetch_cli.py (Sonar flagged 19.2% duplicated lines on new code in #354, gate <=3%). The block now lives in _seed_current (day_lag for the tolerance fixture, skip_day for the gap fixtures, replacing _seed_with_hole), and the two day-only stale fixtures share _seed_stale_days. No behavior change: bar counts and timestamp semantics are identical, and the same tests pass before and after (19/19 in the module; full suite 2951 passed, 1 skipped).

chore(release): 0.9.1 (#356)

Version bump across the six pyprojects for the 0.9.1 release.

Ships since 0.9.0:

  • #349 / #354 — keel fetch (and the TUI’s _do_fetch twin) now honors config.market_data.granularities, so the documented warm step also ensures the FIFTEEN_MINUTE confirmation series every shipped config lists. simulate deliberately keeps [ONE_HOUR, ONE_DAY] (backtest engine limit), pinned by a new test. Intentional, benign behavior change: scheduled/manual fetch on existing deployments now also ensures 15-minute history (the agent already polls it every cycle).
  • #355 — test-fixture dedupe (SonarCloud duplication gate back to green).

Reviewed as #354 (adversarial round + independent final review: goal met, no schema/rails changes) and #355.

chore(release): pin workspace siblings to 0.9.1 (#357)

Follow-up to #356: the bump updated the six version fields but not the ==-pinned sibling dependencies, so tests/test_packaging.py::test_workspace_siblings_are_pinned_to_the_exact_version fails on main (5 failures). This updates every keel-core==/keel-broker-api==/keel-broker-coinbase== pin to 0.9.1.

Verified locally: uv run pytest -q tests/test_packaging.py → all pass. Required before re-running the 0.9.1 release workflow (the previous dispatch was canceled).

v0.9.0

Voir cette version sur GitHub ↗

Built from eebf12b88292. Version binds to this hash: keel --version reports keel 0.9.0+eebf12b88292 [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.9.0-py3-none-any.whl
keel versions

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

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

Configure

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

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

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

Other changes

fix(executor): warn loudly when a conditional entry price is overridden by market routing (#260) (#332)

What & why

Closes #260’s minimum viable mitigation (the issue’s own scope; full resting-order routing stays deferred).

The live executor records every rule’s Setup.entry as expected_fill and then ignores it for execution — all entries route market_market_ioc (_order_row/_order_configuration). For enter-at-close rules that is nearly free; for pullback_continuation, whose entry = signal_candle.high + buffer_ticks deliberately demands follow-through, production silently takes trades the rule meant to decline. The faithful measurement (#258) quantified it across 24 assets: median trade count 58 -> 124 (more than doubled), median gross PF 0.9219 -> 0.7736. The doubling is the count of trades the live box would take that the rule intended to decline, and the PF collapse is their quality.

Fixing it means changing money-moving order routing to rescue a strategy that is independently measured dead — “Upgrading live execution to rescue a dead strategy is a bad trade” (#260). The landmine is not pullback_continuation but the next price-conditional rule, which would be silently mis-executed the same way. So this PR makes the override visible rather than silent — the same principle as #247 printing the fee rate:

  • keel/execution/executor.py gains ENTRY_OVERRIDE_WARN_BP (50bp, documented below) and _warn_if_market_routing_overrides_entry, called from _run_order right after the preview, before the confirm gate.
  • Market reference: the venue’s own best_ask out of the preview _run_order already fetches — the price a market BUY actually pays, from the one book quote already in the hot path (no new broker call; a mid would understate the deviation by half the spread). CoinbaseClient.preview_order maps best_bid/best_ask to Decimal today, and the Coinbase port adapter carries the same book in Preview.detail, so both preview shapes are read.
  • Threshold: ENTRY_OVERRIDE_WARN_BP = Decimal("50") — a VISIBILITY threshold, not a correctness one. Anchored in the repo’s own cost model (1.2% taker per leg, 5bp slippage): a few bp is the microstructure drift any enter-at-close rule accumulates by routing one cycle late; tens of bp is a rule whose entry encodes a condition. 50bp is 10x the slippage assumption (noise never trips it) yet small enough that any deliberate entry condition does. Comparison is strictly greater — exactly at the line logs nothing.
  • The warning is a structured WARNING event (executor.entry_override_market_routed) carrying rule kind, product, intended entry (expected_fill), market reference and its source, signed deviation in bp, the threshold, and an explicit detail sentence: rendered — {"level": "WARNING", "logger": "keel.execution.executor", "event": "executor.entry_override_market_routed", "rule": "pullback_continuation", "product": "BTC-USD", "expected_fill": "50300", "market_ref": "50000", "market_ref_source": "preview_best_ask", "deviation_bps": "60.00", "threshold_bps": "50.00", "detail": "the rule's conditional entry price was OVERRIDDEN -- entries are always routed as market orders (#258), so the condition this rule encoded in its entry price was bypassed and the order is going out at the venue's price instead (#260)"}

Scoped to BUYs on the market configuration only: SELL intents (exits, brackets, stop rolls) carry their prices to the venue verbatim, and a future caller passing a resting order_configuration is not on the override path. A preview with no usable book quote is silent, not fatal. _order_row and the module docstring also document the always-market decision (#258) and why resting orders are deferred (#260).

Tests-first evidence

Tests written first in tests/execution/test_executor.py::TestEntryOverrideWarningAtRouting (extending the existing TestIntentDivergenceLog house pattern), seen red:

FAILED tests/execution/test_executor.py::TestEntryOverrideWarningAtRouting::test_routing_an_offset_entry_warns_loudly_at_warning_level
...
E       AssertionError: no executor.entry_override_market_routed record was emitted
E       assert []
...
7 failed, 1 passed, 63 deselected

The end-to-end routing test (through execute(), no private imports) failed on the assertion meant to assert — the full guard->preview->place path ran clean and no warning fired. The other 7 red on importing the then-nonexistent helper, then went green with the implementation. Cover: beyond threshold via the full routing path (rule kind + both prices + signed bp + WARNING level + the OVERRIDDEN sentence), within threshold silent (a warning that fires every order is a warning nobody reads), exactly at the threshold silent (boundary pinned from the constant), entry below market warns with negative sign, bookless/garbage preview silent and non-fatal, the port Preview shape, SELL intents never warn, non-market configurations never warn.

  • Tests written first, seen failing for the right reason
Gates (all must pass)
  • uv run ruff check clean — All checks passed!
  • uv run mypy clean — Success: no issues found in 237 source files
  • uv run pytest -q green — 2870 passed, 1 skipped in 31.34s
Scope check
  • This PR touches a rail or a default classification — it does NOT: executor logging + tests only (keel/execution/executor.py, tests/execution/test_executor.py).
  • No order-routing change (every entry still routes market per #258), no rule change, no simulator/backtest change, no new dependency.

feat(backtest): per-product slippage scaled from liquidity — assumed, capped, and reported (#259) (#334)

What & why

Closes #259. backtest() charged one global 5bp of slippage on both legs of every trade on every product. Since #257 (entries fill at next bar’s open as market orders), that constant is the ONLY term modelling spread crossing and market impact — precisely what a market order pays. The 24-product corpus spans orders of magnitude of liquidity (median daily quote volume, measured over the cached ONE_DAY bars on 2026-08-16: BTC $571M, ETH $337M, SOL $109M … WLD $1.2M, TON $370K), so 5bp was plausible for BTC and optimistic for the tail by an unmeasured factor — and the error ran in the flattering direction on exactly the thin assets that kept surfacing as apparent outliers (TON gross PF 3.751 on n=9; WLD 2.810 on n=12).

This PR replaces the flat constant — for callers that opt in — with per-product slippage scaled from the liquidity statistic compliance/screen.py::median_daily_quote_volume already computes (no new data source, no network), and reports the assumed rate beside the results, exactly the way #247 made the fee rate visible. A profit factor printed without its assumed slippage has the same problem a profit factor printed without its fee rate had.

The mapping, and every parameter as a conservative assumption (keel/strategy/backtest.py::slippage_for_quote_volume):

slippage = clamp(SLIPPAGE_FLOOR_PCT * sqrt(SLIPPAGE_REFERENCE_QUOTE_VOLUME / median_volume),
                 SLIPPAGE_FLOOR_PCT, SLIPPAGE_CAP_PCT)
  • SLIPPAGE_FLOOR_PCT = 5bp — the liquid-end bound AND the fallback for products with no statistic. Numerically the old global constant: the liquid end was never the problem.
  • SLIPPAGE_REFERENCE_QUOTE_VOLUME = $500M/day — the anchor that maps to the floor. Measured corpus top (BTC $571M) sits just above it, so BTC itself clamps to the floor. A round number, not BTC’s exact median, so the anchor stays honest as the corpus re-measures itself.
  • SLIPPAGE_CAP_PCT = 50bp — the thin-end bound, chosen so it binds at exactly 100x below the anchor (sqrt(100) = 10x the floor — a relationship a reader can recover). TON (~1544x below the anchor) would demand ~184bp unclamped, more than any plausible thin-book spread for the 1-unit notional this engine fills; the cap keeps the model conservative without declaring thin products untreatable by construction.
  • Square-root of inverse volume ratio: the standard practitioner prior for market impact. The mapping is an ASSUMPTION, not a measurement — keel stores no book snapshots or realised spreads — and it is documented and reported as one, monotone (more liquid → never more slippage) and bounded at both ends.

Computed rates for the anchors named in the issue: BTC ($571M) → 5.0bp (floor); a 100x-thinner product ($5M) → 50.0bp (= the cap, by construction); TON ($370K, ~1544x) → unclamped ~184bp, clamped to 50.0bp. On the recent-1y window: ETH 6.0bp, SOL 10.1bp, LTC 38.5bp, PAXG capped at 50.0bp.

API shape: backtest() and report.edge_table() gain slippage_by_product: Callable[[str], Decimal] | None = None, called once per run with rule.product_id. A callable rather than a dict because the caller composes it from its own liquidity statistics plus its own fallback policy, and the flat slippage_pct remains both the default and the per-product fallback — every existing caller’s behaviour is identical until it opts in. The simulate/CLI path opts in: it computes the statistic from the ONE_DAY candles the run already loads and prints/writes the per-product table. Products with no cached daily bars fall back to the flat rate and are flagged “fallback (no liquidity statistic)”.

Callers left on the flat constant, deliberately: keel rules backtest/rules promote (its shared _run_backtest feeds the promotion gate — out of scope by this PR’s own scope check), sim/portfolio_sim.run and the DCA benchmarks (SimAccount’s cost model is unchanged; the report states beside the table that the account pass and benchmarks still price at the flat 5bp), strategy/paper.py, and the annotated experiment records in docs/experiments/, which are untouched.

Tests-first evidence
  • Tests written first, seen failing for the right reason

Red run before any implementation existed:

  • tests/strategy/test_backtest.py — ImportError: cannot import name 'SLIPPAGE_CAP_PCT' from 'keel.strategy.backtest' (the mapping did not exist).
  • tests/sim/test_report.py — ImportError: cannot import name 'SLIPPAGE_FLOOR_PCT' (the assumption type did not exist).
  • tests/test_cli.py::test_simulate_reports_per_product_slippage_beside_the_results — AssertionError: assert 'slippage' in … (simulate printed no slippage; a real behavioural failure, not an import error).

New tests pin: the anchor maps to the floor; a 100x-thinner product pays exactly 10x (50bp, the cap, by construction); the cap genuinely clamps TON’s measured median (unclamped ~184bp asserted > cap); more-liquid-than-anchor never discounts below the floor; weak monotonicity across a 20,000x synthetic ladder with strict increase through the unclamped middle; zero volume → the cap (fail-closed); measured corpus medians (BTC/ETH/TON) land where the corpus says; backtest() integration — a resolver overrides the flat constant on the entry fill, a thin product nets strictly less than a liquid one on identical candles (the direction check), a resolver answering the flat rate is indistinguishable from no resolver, and omitting the parameter reproduces the flat run exactly; edge_table threads the resolver through and without it prices at the flat rate; the report renders the per-product table (volume, bp, capped flag, fallback flag, assumption stated with parameters) and the legacy fee line is byte-identical when no rows are passed; the CLI prints and writes the table, fallback products flagged.

Gates (all must pass)
  • uv run ruff check clean — All checks passed!
  • uv run mypy clean — Success: no issues found in 237 source files
  • uv run pytest -q green — 2887 passed, 1 skipped in 36.17s (the full suite passing is the “every existing caller unchanged” evidence)
Scope check
  • This PR touches a rail or a default classification — cost model + reporting only. No rule, rail, or promotion-gate change: rules promote still prices at the flat 5bp exactly as before. The simulate report’s G2 verdict necessarily inherits the engine’s more conservative pricing — that is the intended direction of #259, not a gate change.
  • New dependency added — none.

chore(fidelity): close Phase 9’s loose ends — alias, stale docstrings, telemetry overflow guard (#336)

What & why

Closes Phase 9’s loose ends, from the independent whole-phase review (all findings non-blocking; this PR is bookkeeping and robustness, not behavior):

  • cli._SIM_SLIPPAGE_PCT is now an alias of backtest.SLIPPAGE_FLOOR_PCT, not a repeated 0.0005 literal. The simulate report asserts its flat-priced dollar sections cost “the flat SLIPPAGE_FLOOR_PCT per leg” — that claim was true by numeric coincidence; a retuned floor would have made the report’s own cost statement silently false. Now structurally true, pinned by a test at the site that renders the claim. (portfolio_sim and paper still carry their own literals — folding them is bundled into #335, the promotion-gate opt-in, so all remaining flat-rate sites move together.)
  • Two stale #332-era docstrings refreshed: _log_intent_divergence no longer claims the per-asset liquidity model “does not exist yet (#259)” — it exists on the research side; the live path just has no statistic at fill time. ENTRY_OVERRIDE_WARN_BP’s rationale now cites SLIPPAGE_FLOOR_PCT (not the CLI literal) and states honestly that “10x the slippage assumption” holds at the liquid end and narrows toward #259’s 50bp cap — plus an explicit note that the threshold’s 50bp and #259’s cap 50bp are different, uncoupled constants.
  • The entry-override warning’s arithmetic moved inside a try: is_finite() admits extreme exponents (a rule bug like 1E+999999999 parses, is finite, compares fine), and the division raises ArithmeticError — which telemetry must swallow, matching the sibling intent_divergence’s inside-the-try arithmetic. Regression-tested.
  • The promotion-gate opt-in deferral now has its tracking issue (#335), prerequisite-gated like #333 — the phase’s one untracked decision is tracked.
Tests-first evidence

The overflow guard was written failing-first conceptually and verified directly (Decimal('1E+999999999') against a quoted preview raises through the old code, returns silently on the new); the regression case is now in test_a_preview_without_a_book_quote_is_silent_not_fatal. The alias pin lives in test_simulate_reports_per_product_slippage_beside_the_results, where the claim it guards is rendered.

Gates
  • uv run ruff check keel tests packages — All checks passed!
  • uv run mypy — Success: no issues found in 237 source files
  • uv run pytest -q — 2887 passed, 1 skipped
Scope check
  • No rail, rule, or classification touched. No behavior change except one: the entry-override telemetry can no longer raise on pathological exponents (strictly toward “never raises”).

feat(promotion): pool min_trades across same-parameter rules, with a diversity floor (#338) (#343)

What & why

The promotion gate judged min_trades = 100 per rule per product. At the measured daily rates (1.19–3.20 trades/asset-year, the go-live runbook’s own table) a single product needs 31–84 years to reach 100 trades — the floor was unreachable in a human timespan, which is why the sandbox’s five rules were live-seeded and why #337 adds an hourly paper profile that makes samples collectable at all. This PR makes the gate able to COUNT that evidence.

The change is to the gate’s unit of evaluation, not its floors — 100 stays 100. This is the agreement CONTRIBUTING requires for a gate change, recorded in the issue: the operator approved it on 2026-08-17. The recorded discipline in promotion.py (the win-rate axis was relaxed ALONE; axes move only with their own justification) is respected: PromotionConfig is untouched.

Concretely, when promoting, sibling evidence is gathered — same kind, same params (exact match on the stored JSON-plain form, minus product_id), different product_id, status paper — plus the candidate itself, once. The sample-size axis passes if EITHER:

  • (a) the per-rule stat clears min_trades exactly as today (a rule with no siblings is judged byte-for-byte as before, and a rule whose own sample is full is still judged on its own stats — pooling is not a quality rescue for the sample-rich), OR
  • (b) pooled n ≥ 100 and a diversity floor: ≥ MIN_POOLED_PRODUCTS (5) distinct products each contributing ≥ MIN_TRADES_PER_PRODUCT_POOLED (10) trades.

The diversity floor is the honest discount on pooling: crypto assets correlate, so a pool of correlated samples carries less information than its trade count claims — pooled-but-correlated evidence overstates its statistical power. Requiring breadth (products each with an independently meaningful sample) is how the pooled path pays for the larger n instead of just collecting it.

Quality floors (expectancy / rr / win rate) are judged on the POOLED aggregates when path (b) carries the decision; on the rule’s own stats otherwise.

The PBO/overfitting gate (G4) stays per-rule, deliberately. It consumes the CSCV result for the trial matrix the rule’s parameters were selected from (--pbo-session) — evidence about the parameter selection, which is per-parameter-set already — and this PR does not change its scope. A pooled promotion without a run overfitting check still refuses, exactly as before.

keel rules promote prints BOTH readings whenever a pool exists — per-rule n, pooled n, and the per-product census — and failure reasons name their path:

rule 1 (pullback_continuation): sample readings -- per-rule n_trades=16, pooled n_trades=128 across 8 products
  pooled census (diversity floor 5 products x >= 10 trades): ADA-USD=16, BTC-USD=16, DOGE-USD=16, ETH-USD=16, LTC-USD=16, PAXG-USD=16, SOL-USD=16, XLM-USD=16 -- 8 products contribute, min contribution 16
rule 1 (pullback_continuation): overfitting check = pass
rule 1 (pullback_continuation): status -> paper
Pooling arithmetic (stated in pool_stats’ docstring)

Pooled from the per-result aggregates BacktestResult actually carries: pooled n = Σn; per-result wins recovered as round(n × win_rate) (the float round-trip error of wins/n is ~1e-16, far below 0.5); pooled win rate = Σwins/Σn; pooled avg_win/avg_loss = win-/loss-weighted means; pooled expectancy/profit_factor/avg_mfe/avg_mae = trade- weighted means / gross sums with summarize’s conventions. max_drawdown and max_losing_streak are NOT pooled — they are path-dependent and unrecoverable from aggregates — and are set to 0; the gate reads neither.

One adjacent fix

transition()’s kind-level row lookup (“newest row of this kind”) predates multi-row kinds; with pools made of same-kind sibling rows it would have advanced a sibling the operator never typed. keel rules promote now pins the target row (rule_id=); the kind-level lookup remains for library callers that don’t name a row (pinned by test).

Closes #338

Tests-first evidence
  • Tests written first, seen failing for the right reason

Red run (tests written before any implementation):

$ uv run pytest tests/strategy/test_promotion.py -q
ImportError: cannot import name 'MIN_POOLED_PRODUCTS' from 'keel.strategy.promotion'
1 error during collection

$ uv run pytest tests/test_cli.py -q -k "pooled or siblings or readings"
test_rules_promote_reports_both_readings_and_promotes_via_the_pooled_path FAILED
  AssertionError: assert 'per-rule n_trades=16' in <output>   # the command printed no readings
test_rules_promote_names_the_diversity_failure_when_the_pool_is_too_narrow FAILED
  AssertionError: assert 'pooled n_trades=120 across 4 products' in <output>
1 failed, 1 passed, 75 deselected   # then: 2 failed after the module tests went red on import

New coverage: single-product pass/fail unchanged (including “no pooled lines at all”); pooled pass (8×16=128, candidate’s own quality fails, pooled quality carries it); pooled fail on n (8×10=80, reason names path+census); pooled fail on diversity (4×30=120 but only 4 products); pooled quality judged on pooled aggregates; a full own-sample rule not rescued by the pool; pooled reading reported even when per-rule passes; params-mismatch and non-paper rows are not siblings; candidate’s own product never double-counted; duplicate sibling rows pool once; transition promotes via the pooled path and still refuses without a run overfitting check; CLI prints both readings + census.

Gates (all must pass)
  • uv run ruff check clean
  • uv run mypy clean
  • uv run pytest -q green
2908 passed, 1 skipped in 38.87s
All checks passed!            # ruff
Success: no issues found in 237 source files   # mypy
Scope check
  • This PR touches a rail or a default classification — checked means it DOES; leave checked only if true, and if so: cite the source and open the discussion BEFORE review (CONTRIBUTING.md, “Governance: rulings vs. machinery”).
  • New dependency added (needs discussion first)

feat(paper): an hourly-cadence paper profile — evidence at a collectable rate (#337) (#344)

What & why

The paper deployment evaluates once per UTC day and daily-turtle rules fire 1.19–3.20 times per asset-year — the promotion gate’s n=100 is 31–84 years away. The same rules on ONE_HOUR bars fire ~50/asset-year (median n≈268 over the cached 5-year window), making the sample collectable in months. The honest caveat, stated up front because it changes nothing about the decision: the hourly configuration is measured NET-NEGATIVE — 0 of 90 / 0 of 82 cells at every reachable fee (docs/experiments/2026-08-13-restated-under-a-production-faithful-engine.md). This profile exists to produce ADMISSIBLE EVIDENCE — rail vetoes, outcomes, pending lifespans, intent divergence: the things a backtest cannot observe — not profitability. That caveat is pinned in the config header, the plist comment, and the runbook by tests, so it cannot silently disappear.


Investigation
1. How does the agent map rules to candle granularity?

A cycle feeds each rule candles of the rule’s DECLARED granularity, not the profile’s configured granularity and not “whatever was fetched”. The plumbing:

  • agent.run_once polls and reads every granularity the profile configures (keel/agent.py:855 — granularities = list(config.market_data.granularities); :1064 — candles_by_tf = {g: repo.get_candles(product_id, g) for g in granularities}). The profile’s config is the menu, not the choice.
  • The choice is the rule’s own attribute: engine._trading_granularity (keel/strategy/engine.py:225-237) returns rule.granularity / rule.timeframe if declared, else the finest available. agent._entry_gate_granularity (keel/agent.py:221-249) reads the same attributes to gate entries (falling back to the coarsest configured series for rules that declare neither — the DCA hazard). backtest._rule_trading_tf (keel/strategy/backtest.py:208-218) does the same for backtests.
  • Turtle’s granularity was a hard-coded attribute, not a param: self.granularity = Granularity.ONE_DAY fixed in __init__ (keel/strategy/rules/turtle_breakout.py, pre-PR line 140), and detect()/exit_signal() read the ONE_DAY key unconditionally via _completed_days. No configuration could point the rule at another series — which is why the 2026-08-11 hourly corpus had to hand hourly bars to a rule that “believed they were days” (docs/experiments/2026-08-11-hourly-backtest-turtle-breakout.md §2/§7: backtest keys the fetched ONE_HOUR series under the rule’s declared ONE_DAY, “the rule cannot tell”).
  • Trading granularity is configured per rule row (rules.params), constrained by the profile’s market_data.granularities: PullbackContinuation declares granularity (constructor param, default ONE_HOUR) and RsiMeanReversion declares timeframe (dataclass field, persisted as self.timeframe.value, coerced back by agent._GRANULARITY_PARAMS). Pullback is the cautionary tale: it accepts granularity but does NOT persist it in describe()["params"], so rules add refuses it rather than silently rebuild the rule at the default on a different candle series (keel/commands/rules.py:822-824, 967-984).

Consequence: an hourly paper profile needs turtle’s declaration itself to be a param. This PR adds it (see below).

2. Can an hourly cycle keep ONE_HOUR bars current within venue limits?

Yes, comfortably:

  • poll_once (keel/data/market_feed.py:184-221) fetches, per (product, granularity) pair, only candles strictly newer than the latest stored and no later than the most recently closed one. Steady state at hourly cadence: ~one small request per pair per cycle. This profile: 5 traded products × 3 configured series = ~15 read-only candle requests per hour (ONE_DAY accrues 1 bar/day, ONE_HOUR 1/hour, FIFTEEN_MINUTE 4/hour) — far under Coinbase’s public-endpoint budget, and identical in shape to what the live profile’s 24 hourly triggers already do today.
  • The 350-candle cap self-heals rather than wedging. Coinbase rejects ranges over ~350 candles; MAX_CANDLES_PER_REQUEST = 300 (keel/data/history.py:30) and market_feed._request_windows/_poll_catch_up (keel/data/market_feed.py:61-77, 150-181) tile any catch-up range into ≤300-candle windows, upserting per window. repair.py applies the same chunking to interior gaps and records “absent at source” windows so permanently empty ones stop retrying. So a machine that was off for days catches up in ONE poll — 300 hourly bars ≈ 12.5 days per window, and windows repeat.
  • Freshness gates align with the cadence: rail 12’s staleness window is interval_sec × FEED_STALENESS_CYCLES (keel/execution/guards.py:125) = 3600×3 = 3h here, on the finest configured series (FIFTEEN_MINUTE). The entry gate freshness.entry_bar_ready (keel/data/freshness.py:126-237) for an ONE_HOUR-declared rule requires the newest stored hourly bar to be the expected one and every finer series to have crossed the boundary — the :20 trigger’s 20-minute publication margin (same margin com.keel.live uses for the same reason) keeps that from racing; when it does race, the cycle exits DATA_NOT_READY_EXIT=4, the hour goes unstamped, and the next trigger retries.
3. What defines a “profile”?

Four tracked artifacts per profile (tracked in-repo since 2026-08-03 per docs/RELEASING.md:101-104; the scripts’ older “gitignored” header lines are stale on that point and this PR’s new files say “tracked”): config (config.paperforward.yaml / config.live-sandbox.yaml), database (keel.db / keel-live.db — the --db CLI flag, default keel.db), launchd plist (com.keel.paperforward.plist — local-anchored, 09:00–20:00 hourly triggers + RunAtLoad, LOCAL day-stamp; com.keel.live.plist — 24 hourly triggers at :20, UTC day-stamp, where the stamp is a correctness mechanism because nothing on the live path dedupes an entry, pinned by tests/test_schedule.py), and run script + wrapper (paperforward-run.sh + keel-paper; keel-live-run.sh + keel-live, the wrapper pinning --config X --db Y together so --db’s keel.db default can never silently cross the ledgers). The operator-facing contract for the pair lives in docs/operator-runbook.md “Paper vs. live”. This PR adds the third profile in exactly that shape: config.paper-hourly.yaml + keel-paperhourly.db + com.keel.paper-hourly.plist + paper-hourly-run.sh + keel-paperhourly.


The code change (and the default-compatibility story)

TurtleBreakout gains granularity: Granularity = Granularity.ONE_DAY:

  • Declared exactly the way RSI declares timeframe (the convention that round-trips): persisted in params as granularity.value (a JSON-plain string), registered in agent._GRANULARITY_PARAMS so build_rule_from_params coerces "ONE_HOUR" back to the enum. Not Pullback’s non-persisted convention, which rules add refuses.
  • detect()/exit_signal() read self._trading_series(candles_by_tf): the ONE_DAY default keeps _completed_days’ forming-bar guard verbatim (the account-sim lookahead guard, pinned by existing tests); any other declared granularity reads that series verbatim — the same contract pullback/rsi already trade under (agent persists only closed candles; the sim decides at the current bar’s close). An absent key declines as insufficient history rather than silently falling back to another granularity’s bars.
  • Default compatibility: turtle rows already exist in keel.db and keel-live.db with no granularity key; they rebuild at the ONE_DAY default and keep meaning exactly what they meant. This is the asymmetry _params_delta (keel/commands/rules.py:734-740) already documents for any kind that grows a param, and deploy/live-rules.json is deliberately NOT regenerated (it mirrors the live rows, which are unchanged). Pinned by test_a_row_written_before_the_param_existed_defaults_to_daily.
  • Everything downstream follows with no further code: the entry gate (_entry_gate_granularity), the engine’s trading-TF and higher-TF bias gate (an hourly turtle gets the ONE_DAY bias check, like every hourly rule), and backtest._rule_trading_tf all read the declared attribute.
The deployment profile
  • config.paper-hourly.yaml — paperforward’s exact universe (same allowlist/weights/caps/fees/paper seed, pinned by test) at interval_sec: 3600, header carrying the net-negative caveat and the separate-database contract.
  • com.keel.paper-hourly.plist — 24 hourly triggers at :20 + RunAtLoad, comment blocks matching the existing plists (including the XML no-double-hyphen rule).
  • paper-hourly-run.sh — its own stamp semantics: stamps the UTC hour (date -u '+%Y-%m-%dT%H'). The paperforward day-stamp is daily-grained and would collapse 23 of 24 cycles into no-ops (the exact regression a copy-paste would ship — mutation-checked red, see below). The stamp is cadence bookkeeping here, not the live path’s duplicate-entry barrier: paper already refuses a second entry while a product is open (strategy/paper.py), but duplicate cycles would still inflate the rail-veto/no-signal evidence counts.
  • keel-paperhourly — wrapper pinning --config config.paper-hourly.yaml --db keel-paperhourly.db.
  • docs/operator-runbook.md — third column in the paper-vs-live table + “The hourly evidence profile” section: purpose (evidence cadence), the net-negative caveat, the separate database with exact bootstrap (keel migrate --db, per-product rules add --params '{"granularity": "ONE_HOUR"}', deliberate rules promote --force with the reason, keel fetch), the one-param difference from every other turtle row, and the honest limits (an hour lost to power-off is lost; the runner cannot replay bars).
Tests-first evidence
  • Tests written first, seen failing for the right reason

Red (before the param existed), failing on the assertion meant to assert — not an import error:

FAILED tests/strategy/test_turtle_breakout.py::TestDeclaredGranularity::test_the_default_is_daily_and_is_persisted
    assert rule.params["granularity"] == "ONE_DAY"
E       KeyError: 'granularity'

plus test_an_hourly_rule_detects_on_the_one_hour_series (TypeError: unexpected kwarg) and tests/test_agent.py::test_coerced_param_keys_... (frozenset mismatch). Green after the change: same tests pass, full suite below.

For the runner’s regression tests (written after the runner, so discrimination was proven by mutation instead — both mutations confirmed red, then reverted and re-verified green):

  • mutating the stamp to daily-grained (date -u '+%Y-%m-%d') → test_the_next_utc_hour_runs_its_own_cycle FAILED (1 failed);
  • dropping the --db keel-paperhourly.db pin → test_the_cycle_runs_the_hourly_config_against_its_own_database FAILED (1 failed).
Gates (all must pass)
  • uv run ruff check clean — All checks passed!
  • uv run mypy clean — Success: no issues found in 238 source files
  • uv run pytest -q green — 2909 passed, 1 skipped in 36.85s
Scope check
  • This PR touches a rail or a default classification — unchecked: no rail, no gate (#338’s PR owns that), no change to the live or paperforward profiles’ semantics (their rows, configs, plists and runners are untouched; config.paperforward.yaml/com.keel.paperforward.plist/paperforward-run.sh diff-free).
  • New dependency added — none.

Closes #337

feat(status): surface rail-17 attestation expiry before it vetoes, and a rules enable verb (#340) (#345)

Closes #340

What & why

On 2026-08-14 rail 17 vetoed the only live DCA signal because the withdrawal-capability attestation had expired; as of 2026-08-17 both deployments’ attestations were weeks stale, so rail 17 was halting entries everywhere — and nothing surfaced that until the veto fired. This PR makes the staleness VISIBLE before it vetoes, records the weekly refresh habit beside the other account-level obligations, and closes the re-enable gap #339 depends on. The typed re-attestation stays a human terminal action BY DESIGN — this is visibility and cadence, never automation.

  • keel status rail-17 line (also rendered by keel tui, styled alert on every halted state): a new WithdrawalAttestationStatus on the existing StatusReport, resolved through the executor’s own _withdrawals_enabled and aged with the executor’s own WITHDRAWAL_ATTESTATION_TTL_SEC — never a restated 7 days — so the display cannot call an attestation fresh on the very cycle the rail vetoes it:
    • rail 17 (withdrawal capability): attested, expires in 3d
    • rail 17 (withdrawal capability): EXPIRED 12d ago -- entries halted; re-attest with keel withdrawals attest
    • rail 17 (withdrawal capability): never attested -- entries halted; re-attest with keel withdrawals attest
    • (plus the fresh-deliberate case) ... SUSPENDED -- entries halted; re-attest with keel withdrawals attest --enabled
  • Runbook cadence note (docs/operator-runbook.md, item 3 beside the USDC-rewards note): the weekly re-attestation habit, naming the consequence of missing it (rail 17 fails closed on unknown → live DCA buys vetoed, the 2026-08-14 event) and why the typed confirmation must stay human — a scheduled job must never release a §65.4 halt.
  • keel rules enable <rule_id>: the inverse of rules disable’s WRITE. Verified that disable records nothing about prior status (it stamps only demoted_at), so enable restores to candidate — the bottom of the ladder — and prints the path onward (rules promote, gated; --force as the documented bypass for paper-forwards that can never reach the min_trades floor, e.g. DCA). The docstring documents that a rule disabled from live lands at candidate, not live. This is the supported CLI path #339’s “re-enable the disabled paper DCA twins” needs.
Tests-first evidence
  • Tests written first, seen failing for the right reason

Red run (all 11 new/extended assertions failing on the pre-change code, none for an import error — the rail-17 gather tests fail on the missing withdrawal_attestation field, the render/CLI tests on the missing line, the enable tests on No such command 'enable'):

FAILED tests/commands/test_status.py::test_rail17_attested_fresh_shows_time_remaining
FAILED tests/commands/test_status.py::test_rail17_expired_names_the_halt_and_the_fix
FAILED tests/commands/test_status.py::test_rail17_never_attested_is_said_as_such
FAILED tests/commands/test_status.py::test_rail17_suspended_attestation_still_names_the_halt
FAILED tests/commands/test_status.py::test_rail17_freshness_uses_the_executors_own_ttl
FAILED tests/commands/test_status.py::test_rail17_line_sits_beside_rail11
FAILED tests/commands/test_status.py::test_status_command_runs_read_only_and_prints_key_facts
FAILED tests/commands/test_status.py::test_status_command_json_flag_emits_parseable_json
FAILED tests/test_cli.py::test_rules_enable_restores_a_disabled_rule_to_candidate
FAILED tests/test_cli.py::test_rules_enable_on_a_non_disabled_rule_is_an_error
FAILED tests/test_cli.py::test_rules_disable_then_enable_round_trip_lands_at_candidate
9 failed, 2 passed, 95 deselected

The TUI rail-17 styling tests were likewise seen red (5 failed) before the TUI line was added.

Gates (all must pass)
  • uv run ruff check clean — All checks passed!
  • uv run mypy clean — Success: no issues found in 238 source files
  • uv run pytest -q green — 2945 passed, 1 skipped in 35.38s
Scope check
  • This PR touches a rail or a default classification — checked means it DOES; leave checked only if true, and if so: cite the source and open the discussion BEFORE review (CONTRIBUTING.md, “Governance: rulings vs. machinery”).
  • New dependency added (needs discussion first)

No rail, guard, or classification changes: rail 17’s veto logic is untouched; this PR only reads the same state through the executor’s own resolver. Stale comments claiming disabled is terminal were updated (rules.py disable docstring, promotion.py _PROMOTE_NEXT note) to name the new operator verb.

chore(release): 0.9.0 (#346)

What & why

Version bump across all six distributions; closes #339’s first checkbox. Minor (0.9.0), not patch: the changes since v0.8.1 are a feature set —

  • #334 per-product backtest slippage scaled from liquidity (5–50bp, assumed/capped/reported) — the honest cost model;
  • #332 the executor’s routing-time WARNING when a conditional entry is overridden (#260’s mitigation);
  • #336 the Phase 9 cleanup (floor alias, stale docstrings, telemetry overflow guard);
  • #338 pooled min_trades with a cross-sectional diversity floor — the promotion gate can now count evidence collected across the universe;
  • #337 the hourly-cadence paper profile (turtle’s granularity became a persisted param, daily default unchanged) — evidence at a collectable rate;
  • #340 rail-17 attestation expiry visible in keel status/TUI before it vetoes, and the keel rules enable verb.

No rail, rule, or default classification changed semantics; the promotion gate changed its unit of evaluation per the operator-approved #338, floors untouched.

Tests-first evidence

tests/test_packaging.py pins the sibling-== invariants; after the bump and uv lock: 2947 passed, 1 skipped; ruff clean; mypy clean.

Gates
  • uv run ruff check keel tests packages — All checks passed!
  • uv run mypy — Success: no issues found in 238 source files
  • uv run pytest -q — 2947 passed, 1 skipped
Scope check
  • Version numbers and uv.lock only.

v0.8.1

Voir cette version sur GitHub ↗

Built from 498e02c8f571. Version binds to this hash: keel --version reports keel 0.8.1+498e02c8f571 [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.8.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

fix(fetch): window-scope the gap-proven display — the two counts must share a window (#330)

What & why

keel fetch printed, for real series today (2026-08-17):

GAPS  SOL-USD  ONE_HOUR  n=43640  1 bars behind, 158 internal gaps (-2 proven absent at venue)
GAPS  SOL-USD  ONE_DAY   n=1819   0 bars behind, 5 internal gaps (-1 proven absent at venue)

A negative “proven absent” count is an impossible claim — you cannot prove fewer than zero gaps absent — and it MASKED the real state: for those series the whole-series unexplained-gap count was 160 hourly / 6 daily (some gaps are NOT proven absent), plus 2+1 missing bars sit OUTSIDE the assessed window, yet the display read as if reconciliation had happened.

Root cause: _assess_products computed coverage()’s gap count over a WINDOW-BOUNDED read (get_candles(product, gran, start_ts, None)) but unexplained_gap_count() over the WHOLE series, then _print_freshness subtracted one from the other. Any bar missing older than start_ts drove the suffix negative.

The fix makes the two counts share a window by construction:

  • unexplained_gap_count gains an optional start_ts (default None = whole series, behavior identical to today). Given start_ts, it reads the same bounded slice coverage() reads, so detection covers the same bars. The docstring records the honest boundary caveat: a hole that straddles start_ts is not interior to the bounded slice, so neither count can see it — the window can only under-report a hole crossing its own start boundary, never claim one absent, and the whole-series default still sees it.
  • _assess_products passes start_ts; _print_freshness keeps proven = gaps - unexplained with NO max(0, ...) clamp — the point is the shared window, and a clamp would only re-hide a future mismatch.

--fail-on-gaps scope deliberately UNCHANGED (whole series). Reading the fetch flow: the flag judged _assess_products’ whole-series return, and the advice it prints (run keel fetch --repair-gaps) points at repair_series, which itself reads the whole series — so a hole older than the fetch window is still fixable, still unproven, and still the flag’s business. The flag now computes its whole-series count where it is judged (the --check branch) instead of riding the display’s window-bounded number, and a test pins that a gap lying entirely OUTSIDE the fetch window still fails --check --fail-on-gaps (and that plain --check still reports it in the closing “N have UNEXPLAINED gaps” message). The default of unexplained_gap_count stays whole-series and is pinned by test.

Tests-first evidence

Red first (uv run pytest tests/data/test_fetch_cli.py tests/data/test_gap_repair.py -q):

4 failed, 40 passed in 1.36s

Failing for the right reasons:

  • test_the_gap_suffix_shares_the_fetch_window_so_it_cannot_go_negative — assertion failure whose diff SHOWS the bug (the exact old rendered line): GAPS SOL-USD ONE_DAY n=363 0 bars behind, 2 internal gaps (-1 proven absent at venue)
  • test_every_assessed_row_keeps_proven_absent_never_negative — AssertionError: ('SOL-USD', ONE_DAY, 2, 3): the field shape (window gaps 2 < whole-series unexplained 3).
  • test_unexplained_gap_count_bounded_ignores_holes_before_start_ts and test_a_hole_straddling_start_ts_is_invisible_to_the_bounded_count — TypeError: unexplained_gap_count() got an unexpected keyword argument 'start_ts' (the interface under test not existing yet).
  • test_fail_on_gaps_still_judges_holes_older_than_the_fetch_window was BORN GREEN, on purpose and stated in its docstring: it pins preserved --fail-on-gaps semantics, not the regression — it must pass both before and after or the flag’s scope changed.

Fixture = the field repro: (a) an in-window hole recorded absent in candle_gap_probes, (b) an in-window hole NOT recorded, (c) a 2-bar hole entirely older than start_ts. Before → after, exact rendered line:

- GAPS     SOL-USD      ONE_DAY   n=363     0 bars behind, 2 internal gaps (-1 proven absent at venue)
+ GAPS     SOL-USD      ONE_DAY   n=363     0 bars behind, 2 internal gaps (1 proven absent at venue)

The suffix now claims exactly the one proven bar; the unproven in-window hole rides in the 2 internal gaps count unclaimed; the outside-window hole perturbs neither number, and the closing message still carries the whole-series truth (1 have UNEXPLAINED gaps).

  • Tests written first, seen failing for the right reason
Gates (all must pass)
  • uv run ruff check clean — All checks passed! (over keel tests packages)
  • uv run mypy clean — Success: no issues found in 237 source files
  • uv run pytest -q green — 2862 passed, 1 skipped in 36.56s
Scope check
  • This PR touches a rail or a default classification — unchecked: display + one pure function’s optional parameter only. No rail, no rule, no classification, no broker surface, no default changed (--fail-on-gaps whole-series semantics preserved and pinned by test).
  • New dependency added — none.

chore(release): 0.8.1 (#331)

What & why

Version bump across all six distributions. Patch release: exactly one change since v0.8.0 — #330, the window-scoped gap-proven display. keel fetch was computing proven = row.gaps - unexplained from two counts over different windows (coverage’s window-bounded read vs. the whole-series unexplained count), printing impossible negative suffixes like “158 internal gaps (-2 proven absent at venue)” for real series, and masking that those gaps were largely unproven. The fix makes both counts read the identical slice; --fail-on-gaps keeps its whole-series scope deliberately (the --repair-gaps remediation reads unbounded too).

Tests-first evidence

tests/test_packaging.py and tests/test_python_floor.py pin the invariants this bump touches; after the bump and uv lock: 2862 passed, 1 skipped; ruff clean; mypy clean.

Gates
  • uv run ruff check keel tests packages — All checks passed!
  • uv run mypy — Success: no issues found in 237 source files
  • uv run pytest -q — 2862 passed, 1 skipped
Scope check
  • Version numbers and uv.lock only; no code, rails, rules, or classifications touched.

v0.8.0

Voir cette version sur GitHub ↗

Built from a37997f48cf7. Version binds to this hash: keel --version reports keel 0.8.0+a37997f48cf7 [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.8.0-py3-none-any.whl
keel versions

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

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

Configure

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

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

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

Other changes

ci: gate on mypy, so #266’s ungating cannot quietly come undone (#268)

mypy ran in no workflow before this — it appeared only in a passing comment in code-quality.yml. #266 brought keel.* under the checker, but nothing enforced it: a type error in keel/ was a clean CI run.

Why a step, not a job

Added to the existing test job rather than as a typecheck: job of its own. The main ruleset requires the status context test, which comes from that job’s id — a separate job would report a context nothing requires, so a red mypy would not block a merge. That is the same failure shape ci.yml’s own header already warns about for renamed jobs. As a step, it inherits the gate.

release.yml gets it too, and deliberately re-runs it rather than trusting CI’s result: that workflow is dispatched against whatever main is at the time, which need not be a commit any CI run went green on.

Neither workflow repeats the paths — they come from [tool.mypy]’s files in pyproject.toml, so a package moving in or out of the checked set stays a one-line edit there instead of one mirrored across two workflows.

CI only closes half the hole

Running mypy catches a type error in keel/. It cannot catch keel.* being re-added to an ignore_errors override — that silences the package wholesale, mypy exits 0 while checking nothing there, and #266’s ungating is reverted with a green build to show for it.

test_keel_is_not_exempt_from_type_checking closes that. Both halves verified:

scenariomypy exitsuite
type error injected into keel/sim/report.py1 (fails CI)—
keel.* restored to ignore_errors0 (CI green)new test fails
neither0green

The middle row is the point: the guard catches exactly what CI cannot see.

tests.* and keel_core.* remain legitimately exempt, per the comments beside each in pyproject.toml. The guard pins only the module that was deliberately brought under the checker.

Verification
  • mypy — clean, 224 source files
  • ruff check keel tests packages — clean
  • pytest — 2727 passed, 1 skipped (2726 + the new guard)

fix(data): chunk poll_once’s catch-up request under Coinbase’s candle cap (#269)

The bug

poll_once requested its entire catch-up range in a single get_candles call:

fetch_start = last_ts + gran_sec if last_ts is not None else latest_closed
fetched = client.get_candles(product_id, granularity, fetch_start, latest_closed)

Coinbase rejects any range over ~350 candles:

400 INVALID_ARGUMENT ... "number of candles requested should be less than 350"
Why it can’t recover on its own

The request size is a function of how stale the product is, and staleness only grows while the poll is failing. Every subsequent poll therefore asks for a strictly larger range and fails the same way — there is no path back to a legal request without intervention.

The live instance: ZEC-USD at ONE_HOUR sat 552 hours stale, i.e. a ~552-candle request, and had been failing hourly for two days, widening by ~24 candles a day. The other 18 products were current, so nothing else masked or explained it.

Note the empty-repo case was never affected: with last_ts is None, fetch_start = latest_closed, so exactly one candle is requested. Wedging requires cached-but-stale candles.

The fix

poll_once now pages [fetch_start, latest_closed] forward in windows of at most MAX_CANDLES_PER_REQUEST candles, importing the existing constant from keel.data.history rather than declaring a second one — a duplicated venue limit is the same bug class being fixed here.

The new _poll_catch_up helper mirrors history._fill_forward’s windowing idiom so the two read the same way, with two deliberate differences:

  • windows are sized (MAX - 1) * gran_sec, so the inclusive [start, end] range is at most MAX candles (_fill_forward’s + MAX * step is one over, harmless under the ~350 cap but not a property worth copying);
  • an empty window does not break the loop, since a mid-history hole must not block catch-up of newer candles.

Each window is upserted as it arrives, so a failure partway through leaves the earlier windows persisted and the next poll resumes further along instead of restarting.

cb_client.get_candles is deliberately unchanged — it correctly passes start/end through, and batching belongs in the caller.

Preserved exactly: the small-range path (still a single request), the empty-repo path (still one candle at latest_closed), the last_ts >= latest_closed no-op, and the return value’s meaning.

Tests (written first, confirmed failing for the right reason)

Against the old code the three new chunking tests fail on the oversized single call — assert 552 <= 300 and assert 1 > 1 — not on any import or fixture error:

FAILED test_poll_once_chunks_a_gap_larger_than_the_coinbase_cap
FAILED test_poll_once_never_requests_more_than_the_candle_cap
FAILED test_poll_once_chunk_windows_are_contiguous_and_non_overlapping
3 failed, 12 passed

They reproduce the real 552-hour ZEC-USD gap and assert that catch-up issues multiple calls and persists the complete contiguous series, that no request exceeds MAX_CANDLES_PER_REQUEST candles (asserted on the start/end args the fake client actually received), and that the windows tile the range exactly — no duplicate and no gap at a chunk boundary. A fourth test guards that a small gap still takes exactly one request.

Gates

…truncated — full description in #269.

fix(compliance): stop discovery hiding assets the gate would admit (#270)

The problem

keel assets discover was silently dropping assets that clear the admission gate comfortably, and its own output gave an operator no way to notice. Two defects, one symptom.

(a) A one-day statistic compared against a multi-year threshold

--min-volume-24h defaulted to 1,000,000, pinned EQUAL to the admission floor ScreenPolicy.min_median_daily_volume.

That pinning (2026-08-08, superseding a 5,000,000 floor that had hidden FET at $2.94M/24h while it measured 4.8x the admission floor) had the right intent: a pre-filter must never be stricter than the criterion it screens for. It had the wrong mechanism. The two numbers are not the same statistic:

statisticwindow
discovery --min-volume-24hvenue’s reported quote_24h_volumea single 24-hour snapshot
admission min_median_daily_volumemedian of volume x closeall cached history

Equal numbers cannot make one non-stricter than the other when the two sides measure different things — one quiet trading day pushes the snapshot below a floor the asset’s own median clears many times over.

Measured 2026-08-15, five assets were silently dropped whose real gate statistic sits far above the admission floor:

assetmedian daily quote volumevs admission floor
ATOM3,077,4743.08x
AAVE6,315,4636.32x
BCH5,464,9405.46x
CRV3,329,7533.33x
ALGO3,780,2073.78x

Four of the five had 24h volumes clustered between 852,133 and 979,000 on that one quiet day — comfortably under the 1,000,000 floor.

The discovery floor now sits an order of magnitude below the admission floor (100,000), leaving real room beneath that measured cluster. This supersedes the 2026-08-08 pinning without abandoning its goal: the test that asserted the two floors are equal now asserts discovery’s is strictly less than the gate’s, which is the property the original fix was reaching for. The admission floor itself is untouched — it is the real criterion.

(b) Nothing recorded what was excluded

discover_candidates dropped products with a bare continue and returned only survivors; the CLI printed only N venue products -> M candidates. A filter that can silently remove admissible assets must be auditable from its own output.

It now returns a DiscoveryResult carrying per-reason exclusion counts, surfaced by both keel assets discover and the TUI’s discover overlay:

5 venue products -> 2 candidates (quote=USD, 24h volume >= 100,000, excluding the current allowlist)
excluded 3: wrong quote currency 1, not online 0, trading disabled 0, view only 0, already on allowlist 1, unreadable 24h volume 0, below 24h volume floor 1

Every reason is listed even at 0, so the line has a fixed shape an operator can scan rather than one that changes with the data. The new named return type — rather than a tuple — is deliberate: an un-updated caller fails loudly at the attribute access instead of silently mis-indexing. Candidate is unchanged, and discover_candidates stays pure and offline.

Changes

…truncated — full description in #270.

fix(data): chunk the remaining candle-request windows under the venue cap (#271)

The bug

Coinbase rejects any candle request spanning more than ~350 candles:

400 INVALID_ARGUMENT ... "number of candles requested should be less than 350"

#269 fixed this in market_feed.poll_once. An independent review then found two more sites with the same defect. Both are fixed here, so every candle-request windowing site in the codebase now agrees.

Both are latent. Neither is firing today, and this PR is preventative rather than a repair of live damage. What makes them worth closing is the failure mode: each fails quietly, and one of them fails quietly forever.

Defect 1 — repair.py cannot heal an interior hole larger than the cap

gaps.detect puts no cap on n_missing, so an interior hole can be arbitrarily large. repair_series asked for the whole hole in one call:

fetched = client.get_candles(
    product, granularity, window.start_ts - step, window.end_ts + step
)

A gap of roughly 349+ candles therefore 400s. Because the per-window except swallows the exception into result.errors and continues, the failure is silent and permanent — every scheduled repair retries the same oversized request, and the only symptom is a series that quietly never repairs.

This is not currently triggering

Measured across the deployment (24 products, ONE_HOUR and ONE_DAY), the largest interior gap is:

productgranularityinterior gap
ICP-USDONE_HOUR15 bars
WLD-USDONE_HOUR6
PAXG-USDONE_HOUR6
ZEC-USDONE_HOUR5

Series with an interior gap ≥ 349, i.e. that repair cannot heal: 0. The worst hole in the entire cache is 15 bars against a ~349 limit.

So the justification is “close a latent trap before it bites”, not “heal an existing hole”. It still earns its place: an interior gap that large is entirely reachable — a multi-day venue outage, a delisting and relisting, or a product added back after a long absence — and a silent, self-perpetuating failure is precisely the kind that goes unnoticed until much later.

Correcting the record: an earlier draft of this PR claimed the 553-candle ZEC-USD figure from the #269 incident was an interior hole repair could never heal. That was wrong. Those 553 candles were end-of-series staleness — a missing tail, which poll_once/ensure_history handle by catching up forward. It was never an interior gap, so repair was never going to be asked to heal it. 553 survives in this PR only as the oversized-gap size used in the tests.

The fix

The widened window is now paged in MAX_CANDLES_PER_REQUEST-sized chunks via a new _fetch_window_chunked helper, importing the constant from keel.data.history rather than declaring a second one — a duplicated venue limit is the same bug class being fixed. Each chunk is upserted as it arrives, so a mid-window failure leaves the earlier chunks persisted and the next run resumes further along instead of repeating the whole doomed request.

Semantics preserved exactly (they are load-bearing and commented):

…truncated — full description in #271.

ci: run every workflow on merge to main, except the release (#272)

The repository became public on 2026-08-15. GitHub bills no Actions minutes for standard runners on public repositories, so the cost argument that kept code-quality and migrate off push no longer applies.

Final trigger set
workflowtriggerson merge to main?
ci.ymlpull_request, push [main], workflow_dispatchyes (unchanged)
code-quality.ymlpush [main], schedule, workflow_dispatchnew
migrate.ymlpush [main], workflow_dispatchnew
release.ymlworkflow_dispatchno — deliberately
release.yml stays manual

By explicit instruction, and it could not work otherwise: its version input is required, a push event supplies no inputs, so the job would fail at its first validation step. Its own header already states the rule — “Nothing about a money-moving tool should ship on a merge.”

The safety property that makes migrate on push sound

This is a property of the event, not of anyone’s care. db_path is a workflow_dispatch input; a push carries no inputs, so on a merge ${{ inputs.db_path }} renders empty and the job always takes its migration_smoke.py branch. No merge can supply a value that makes it write to a real database. The comment says so, and says not to add a default target or read one from a repo variable — either would remove the property.

code-quality keeps its schedule, and still has no pull_request

The weekly run is kept rather than replaced: a CVE published against an unchanged pin is invisible to a per-merge trigger and obvious to a scheduled one.

pull_request remains absent, and on a public repo that is now a security choice rather than a cost one — fork PRs receive no repository secrets, so every one would fail at the preflight for a reason the contributor cannot fix.

⚠️ Known consequence

SONAR_TOKEN and SNYK_TOKEN do not exist at repo or org level, so code-quality will fail its preflight on every merge until they are added. That job exists to say why in one line rather than let the scanners fail obscurely — but it will now say it on every merge rather than once a week. Add the two secrets, or say the word and I will make the preflight skip cleanly instead of failing red.

fix(compliance): review follow-ups to #270 — re-pin the admission floor, stop a NaN crash (#273)

Follow-ups from the independent adversarial review of #270. That PR was merged before these landed, so they come as a separate change against current main.

1. HIGH — the admission floor lost its only value pin

#270 changed the floor assertion from DEFAULT_MIN_QUOTE_24H_VOLUME == ScreenPolicy().min_median_daily_volume to <. The relationship guard is right and stays. But it was also the only test pinning the admission floor’s value, and nothing replaced it.

Verified on main before this change: editing ScreenPolicy.min_median_daily_volume from 1000000 to 200000 — a 5× loosening of the criterion deciding which assets a money-moving tool may buy — passed all 2756 tests.

This adds test_the_admission_liquidity_floor_is_pinned_to_its_actual_value alongside the < test. The two are not redundant: one guards the relationship (discovery must never be stricter than the gate), the other guards the criterion itself.

Mutation proof on this branch:

floor -> 200000 :  1 failed, 2760 passed
  FAILED test_the_admission_liquidity_floor_is_pinned_to_its_actual_value
reverted        :  2761 passed, 1 skipped
2. MEDIUM — NaN volume crashed assets discover outright

The parse was inside a try, the comparison was not. Decimal("NaN") parses fine, then < raises decimal.InvalidOperation and takes the whole command down. Separately "Infinity" parsed and silently became a candidate.

Both are now counted as unreadable_volume, with the reasoning commented. Verified end to end:

survived crash -> ['GOOD']
unreadable counted -> 2
accounting: 1 + 2 == 3 -> True
3. MEDIUM — the survivor-count identity was load-bearing but unguarded

render_discover_report derives survivor_count = venue_product_count - excluded.total. Nothing asserted the underlying invariant, and an inconsistent DiscoverReport rendered nonsense (10 venue products -> -89 candidates). Adds a test pinning len(candidates) + excluded.total == len(products), and makes the subtraction defensive.

4. LOW/MEDIUM — the incident record was wrong, and the regression test pinned the wrong end

The comments described the 852,133–979,000 cluster as including ALGO. Measured against the live venue, the cluster is ATOM, AAVE, BCH, CRV; ALGO was a separate low outlier at 437,712.

Worse, the regression test pinned only 852133 — the top. A future floor of 500,000 would have passed it while silently re-hiding ALGO. Re-pinned to ALGO’s 437712, which is what actually constrains the floor. Verified: a floor of 500,000 fails the corrected test and would not have failed the old one.

5. LOW — frozen=True was cosmetic

DiscoveryResult and DiscoverReport were frozen=True but held a mutable list[Candidate] — appendable and unhashable. Now tuple[Candidate, ...].

Gates
ruff check keel tests packages   All checks passed!
mypy                             Success: no issues found in 224 source files
pytest -q                        2761 passed, 1 skipped

test(data): pin that a partially-failed window is never recorded absent (#274)

Follow-up from the independent adversarial review of #271. No production code changes — the shipped behaviour is correct. This closes a hole in the tests.

The gap

repair.py’s most dangerous rule is “a gap window may be recorded absent-at-source only if EVERY chunk completed”. Getting it wrong permanently writes off a hole the venue was never fully asked about.

test_one_bad_chunk_is_not_recorded_absent_and_a_later_window_still_repairs claims to cover this. It doesn’t. It fails the second chunk, so the first chunk lands 299 bars and the surviving window’s key shifts from (BASE+1d, BASE+553d) to (BASE+300d, BASE+553d). probed_keys matches by exact key, so the window is skipped regardless of what probed_ok contains — its windows_absent_at_source == 0 and get_gap_probes() == [] assertions are vacuous in their own setup.

The fix

Fail the first chunk instead. Nothing is upserted, the remaining window keeps its original key, and the probed_keys gate is genuinely exercised.

Verified discriminating

With probed_ok.append(window) added to the failure path:

FAILED test_a_window_whose_FIRST_chunk_fails_is_not_recorded_absent

Note the pre-existing single-chunk test_a_failed_fetch_is_NOT_recorded_as_absent does defend this rule for the one-chunk case — so the review’s “not pinned by any test” was slightly overstated. The real gap was the multi-chunk path, which is what this covers.

Gates
ruff    All checks passed!
mypy    Success: no issues found in 224 source files
pytest  2757 passed, 1 skipped

docs: status review of 2026-08-16 (#275)

A single-page review of where keel stands, for a status meeting on 2026-08-16.

Covers: the live deployment, the one order ever placed and why, rules and strategies, the experiment record, compliance rails, the asset sweep, venue feasibility, engineering and versioning, and the roadmap.

Thesis

The instrument works; the strategies don’t. Across the universe, 0 of 20 rules are net-positive at the taker rate actually paid on hourly bars (median n=268), and 0 of 20 reach n>=100 on daily. Daily and hourly rankings correlate at rho = -0.009. Cost, not signal, is the binding constraint — and establishing that rigorously is the project’s main result to date.

⚠️ Account figures are withheld deliberately

This repository is public. The version presented from carries the live account’s equity high-water mark, the open position’s quantity, entry fill, fee, cost basis and mark, the exact order timestamp, and the configured position caps.

None of that is in this file. It is personal account state, it would enter git history permanently, and no claim in the analysis depends on it. Every structural fact, verdict, measurement and roadmap item is intact, and the page says the figures were withheld rather than leaving a silent gap.

Placement

docs/reports/, alongside the existing docs/superpowers/reports/*.html engine-validation reports, which set the precedent for committing a rendered report.

Verified: parses cleanly, no residual account figures (grep for each withheld value returns nothing), and both light and dark themes are defined at token level.

ci(code-quality): skip cleanly when unconfigured, fail only when dispatched (#276)

Why it’s failing

Exactly what the job was written to report:

Missing: SONAR_TOKEN SNYK_TOKEN
##[error]Process completed with exit code 1

Neither secret exists at repo or org level (gh secret list is empty in both), so the preflight exits 1 and both scans are skipped. Nothing is broken in the code — the workflow is correctly telling us it isn’t configured. It has failed on every merge since #272 added the push trigger.

Why that answer stopped being right

Failing loudly was correct when this ran weekly and on dispatch: an unconfigured repo produced one red X a week, and the message named the missing secrets rather than letting Sonar and Snyk fail with their own unhelpful auth errors.

With push: [main], the same behaviour makes main permanently red for a condition that is not a defect. A red X on every merge is worse than a missing scan — it’s a signal the reader learns to ignore, and it hides the next real failure.

What changes

The preflight now reports rather than decides, and who asked determines the verdict:

triggertokens missingoutcome
workflow_dispatchyesFails, loudly — unchanged. Silently ignoring a direct request is worse.
push / scheduleyesSkips both scans, writes the missing secrets and where to get them to the run summary, emits a ::notice. Build stays green.
eithernoScans run normally.

The scan jobs are gated on a configured output rather than a job-level condition, because secrets can’t be referenced from a job-level if:.

The skip is announced, never silent — the same standard this repo already holds discovery to. A scan that didn’t run and says so is honest; one that quietly does nothing isn’t.

Verified

Ran the preflight script directly in all three states:

  • push, no tokens → exit 0, configured=false, summary renders correctly
  • dispatch, no tokens → exit 1
  • tokens present → exit 0, configured=true
This does not add the secrets

That’s still yours to do, and it’s the real fix — these scans have never run. Once SONAR_TOKEN and SNYK_TOKEN exist, the scans execute and none of the above applies.

docs: record the open-source direction in the status review (#292)

The review was written before the direction was decided. Its roadmap listed four engineering issues and ended on an open strategic question — both now stale.

What changed

A new “Direction” section. The measured result (0 of 20 net-positive) doesn’t mean the project has nothing — it means the strategies were never the asset. The rails are. keel is now positioned as an auditable Shariah compliance engine, with a reference agent attached.

It also states the governance answer where a reader will look for it: keel does not issue rulings. Attestation is a human input that fails closed, nothing is derived from price data, and the ruling lives in the attestation rather than the code — so a Hanafi and a Shafi’i operator can both use it without the project taking a side.

The roadmap is now two tracks.

trackcontents
EngineeringThe same four issues, each now showing its milestone. #259/#260 had none at all — Phase 9 — Execution & backtest fidelity was created for them, alongside the two closed defects of the same class that once voided every performance figure here.
Open source15 issues across three gated phases. Phase 6 is blocking and unglamorous: public but legally all-rights-reserved, so nobody may contribute and nobody can find it.

The strategic question now has a third answer — let the engine be the contribution — sitting alongside reduce-cost and raise-per-trade-edge.

Unchanged

Account figures remain withheld and re-verified — grep for each returns nothing. The page still parses, and both themes still resolve at token level.

docs: move the status review to docs/presentations/, and add the paper → live section (#293)

Two commits: the file move you asked for, and a new section on the paper deployment.

1. Move
docs/reports/2026-08-16-status-review.html
  → docs/presentations/keel-status-review-2026-08-15.html

Establishes docs/presentations/ with a <title>-<date>.html convention. Recorded as a git mv, not a copy — two copies of a deck drift, and the stale one is what someone eventually presents from. Nothing in the repo referenced the old path.

The filename carries the date it was saved; the masthead inside carries 16 August, the date it’s presented. That’s deliberate.

2. Paper → live

The deck mentioned paper in a single clause. It now has a section, because the paper-to-live path is what decides whether a rule may ever touch real money — and its current state is a finding, not a footnote.

What paper is: separate database and config, synthetic cash ($500 → $550), 19 allowlisted assets against live’s six, 20 attested. 22 rules on the books but only 19 load — status isn’t a label, it decides whether the agent reads the rule at all, so one candidate turtle and two disabled DCA rules are inert. Zero signals, orders, positions and outcomes to date.

What it’s for: candidate → paper → live, with a deterministic gate between the last two rungs — n>=100, positive expectancy, RR >= 1.5, a class win-rate floor, and a PBO result. Those floors were deliberately not relaxed together: when win rate was loosened to 0.30 for trend-following, min_trades was held at 100 because the axes are independent and only one had a justification.

Why it can’t work. min_trades is per rule and per product, and does not pool. Measured daily turtle rates are 1.19–3.20 trades/year, putting n=100 31 to 84 years away. Adding assets doesn’t help — a new asset is a new rule with its own ~14 trades, not a pooled 114.

What follows, said plainly: five of the seven live rules carry promoted_at = NULL. They were seeded straight to live and left there, deliberately and on the record; only the BTC dca rule and the DOGE turtle went through the normal path. So what bounds the risk is not the promotion gate, which never ran — it’s the caps, the nineteen rails and the allowlist. The bypass is of the evidence gate, not the safety rails.

Verified

Parses; no stray backticks; account figures still withheld (re-grepped, zero hits).

fix(data): chunk backfill’s candle requests too, the last unpaged window site (#295)

Follow-on to #269 / #271. Supersedes #294, which I opened against 29d9b20 before those landed and have now closed as redundant.

What this is

#269 chunked market_feed.poll_once. #271 swept for the same defect and fixed repair.py and history.py. market_feed.backfill was not reached by either, and has the identical shape — _missing_ranges groups absent timestamps into contiguous ranges, and each range was requested in one call, so a hole wider than the venue’s ~350-candle cap 400s exactly as the poll path did. On an empty repo the entire history window is one such range.

It is latent, and I’d rather say so than dress it up

backfill has no production caller today — keel fetch goes through history.ensure_history. Nothing is failing on this right now.

Worth closing anyway on #271’s own stated grounds, that every candle-request windowing site in the codebase should agree. A public data-layer entry point that 400s the moment anything calls it is a trap left armed for whoever calls it next.

Why it was easy to miss

The (MAX_CANDLES_PER_REQUEST - 1) arithmetic existed only inside _poll_catch_up, so backfill had nothing to be inconsistent with — it just looked like ordinary pre-#269 code. #271 had to correct that exact off-by-one after finding it duplicated in history.py, which is the same bug class the shared MAX_CANDLES_PER_REQUEST import exists to prevent.

So the windowing now has one definition, _request_windows, used by both backfill and _poll_catch_up. _poll_catch_up keeps its own filter and seen dedup and is otherwise unchanged; folding it onto the helper also removed a window_start = window_end + gran_sec line left stranded when its while became a for.

Tests

Written first and confirmed failing for the right reason — both cap tests failed on a single 575-candle request against the unfixed code:

  • test_backfill_never_requests_more_than_the_candle_cap
  • test_backfill_chunk_windows_are_contiguous_and_cover_the_gap
  • test_backfill_still_uses_one_request_for_a_gap_within_the_cap — regression guard, passes before and after
Gates
  • pytest — 2762 → 2765 passed, 1 skipped
  • ruff check — clean
  • mypy keel — clean, 77 source files

chore(license): add Apache-2.0 and declare it in all six distributions (#296)

Closes #277

The repo is public but legally all-rights-reserved: no LICENSE file means nobody may fork, modify, or contribute. This blocks every remaining open-source phase.

What

  • LICENSE at the root: canonical Apache-2.0 (fetched from GitHub’s license API, not retyped), appendix filled with Copyright 2026 CodeGate Software.
  • license = "Apache-2.0" (PEP 639 SPDX form) in all six pyproject.tomls. Verified against a real build: both the root and a sub-package wheel now carry License-Expression: Apache-2.0 in METADATA — the field license scanners read, which the deprecated table form does not produce.
  • CONTRIBUTING.md: why Apache-2.0 over AGPL-3.0 and MIT (patent grant + warranty disclaimer matter for software that moves money), recorded so the decision is challengeable in place.
  • tests/test_licensing.py (written first, red, then green): canonical banner + END OF TERMS marker, filled appendix with no [yyyy] template left, SPDX licence in every distribution, decision recorded. Same repo-hygiene style as test_packaging.py.

Acceptance from #277

  • LICENSE at repo root with the chosen text, copyright line, and year
  • pyproject.toml declares the same licence (all six distributions)
  • gh repo view --json licenseInfo reports it — verified post-merge (GitHub detects the file on main; will confirm and tick)
  • Decision and reasoning recorded in CONTRIBUTING.md

Gates: pytest 2774 passed / 1 skipped, ruff check clean, mypy clean.

Until this merges: do not accept external pull requests — the issue’s warning holds; nothing changes for contributors until this is on main.

docs(governance): state the boundary — keel enforces rulings, it does not issue them (#297)

Closes #280

Once contributors arrive from different madhāhib and jurisdictions they will disagree about rulings, and in a religious context ungoverned disagreement fragments the project. The architecture already answers the question — a classification is a human input (keel assets attest --source --attested-by), screen_asset enforces it, attestation=None is a rejection — so this PR makes the answer explicit, before any other Phase 6–8 document is written against the wrong assumption.

What

  • README’s first screen (before the first ##, where a stranger decides what this is): the quotable sentence — keel is not a fatwa engine. It is an enforcement engine for a ruling you supply. — plus the two-operators-two-schools consequence.
  • CONTRIBUTING.md: the same sentence verbatim, framed as PR scope:
    • a PR changing a default classification needs a cited source and is discussed — it applies one contributor’s fiqh to every operator who upgrades;
    • a PR changing the mechanism is ordinary engineering;
    • disagreement routes locally — keel assets attest writes your ruling to your database — and is not merged upstream, so the project never becomes a fiqh court with a merge button.
  • tests/test_governance.py (written first, red, then green): boundary sentence on the README first screen; same sentence verbatim in CONTRIBUTING (drift-proof); both PR kinds told apart with their bars; the local-not-upstream route named.

Acceptance from #280

  • Short, quotable statement in the README’s first screen
  • Same statement in CONTRIBUTING.md, framed as PR scope
  • Explicit: default-classification PRs need a source and discussion; mechanism PRs are ordinary engineering
  • Stated disagreement route that does not require the project to adjudicate

Gates: pytest 2778 passed / 1 skipped, ruff check clean, mypy clean.

docs(security): add SECURITY.md and a private reporting channel (#298)

Closes #279

keel holds exchange API credentials and places live orders. Once strangers read the source, someone will eventually find a way to make it misbehave — and until now the only options were a public issue (disclosing to everyone simultaneously) or nothing.

What

  • SECURITY.md at the repo root:
    • Private channel: GitHub private vulnerability reporting — the Report a vulnerability button under the Security tab. Enabled in repo settings (verified: gh api .../private-vulnerability-reporting → {"enabled":true}); the file alone gives no channel, per the issue’s note.
    • Scope: the defining class — a rail that can be bypassed is a security issue, not merely a bug (allowlist, caps, drawdown breakers, kill-switch), credential/secret exposure, and corruption of the attestation/audit trail.
    • Out of scope: strategy performance and market losses, a user’s own key handling, the exchange’s own outages.
    • Response expectations, honestly sized for one maintainer: acknowledge within 3 days, severity + plan within 14, coordinated disclosure, credit by default.
  • tests/test_security_policy.py (written first, red, then green): pins the channel, the day-count commitment, the rail-bypass scope line, and the out-of-scope list. The GitHub-side setting is outside the tree and was verified against the live repo instead.

Gates (rebased on main after #297): pytest 2782 passed / 1 skipped, ruff check clean, mypy clean.

docs(contributing): state the bar — documentation standard, gates, tests-first (#299)

Closes #282

The repository’s documentation standard is its signature and its single biggest barrier to contribution — nobody will guess it. An unstated bar filters for clairvoyance: PRs arrive at ordinary quality, get heavy review, and the contributor quietly leaves.

What CONTRIBUTING.md now states

  • The documentation standard, taught from a worked example: the _open_exposure_by_asset docstring in keel/execution/guards.py is quoted in full, then dissected on the three properties that make it acceptable — it says why, it names what was measured, and it says what it would take to change the decision.
  • Dev setup and the gates: uv sync --all-extras --dev, uv run ruff check, uv run mypy, uv run pytest -q — pasteable, because a gate nobody can paste is a gate nobody runs.
  • Tests first, with evidence: failing tests shown to fail for the right reason — an assertion, not an import error — carried in the PR.
  • Conventional Commits, matching history; the prefix is load-bearing for releases.
  • Three-tier scope: welcome / discuss-first (rails, default classifications, new deps, keel-broker-api surface) / out of scope (rulings as defaults, “make the bot profitable”, weakening fails-closed paths).
  • The honest-upfront framing the issue asks for: the bar is high, said so at the top, with why.
  • Governance (rulings vs. machinery) and licence sections from Phase 6 kept intact.

Tests (tests/test_contributing.py, red first): gates stated verbatim; the worked example present in CONTRIBUTING.md and still existing in guards.py (drift-proof, via wrap/blockquote-normalized matching); the three properties named; tests-first + evidence; commit convention; scope tiers.

Gates: pytest 2788 passed / 1 skipped, ruff check clean, mypy clean.

docs(readme): rewrite for the newcomer, led by the compliance engine (#300)

Closes #281

The README was a 261-line operator runbook — right for the author’s future self, wrong for a stranger deciding whether to spend an afternoon here.

The new first screen

  1. What keel is, led by the compliance engine — 19 deterministic rails, attested screening that fails closed, §65.4 qabd as an executable check; the trading agent is the reference implementation on top, not the headline.
  2. The honest result, stated by us first — no shipped rule family net-positive at the taker fee actually paid; viable intersection empty under production-faithful execution, linking docs/experiments/2026-08-13-restated-under-a-production-faithful-engine.md.
  3. The not-a-fatwa-engine boundary (from Phase 6, kept verbatim).
  4. Try it in five minutes — see below.

Quickstart, verified end-to-end on a clean clone (that’s why it says keel rules seed, not keel init: a clone has the tracked config.yaml and init refuses to overwrite it):

git clone … && cd keel
uv sync --all-extras --dev
cp .env.example .env          # free read-only CDP key
uv run keel rules seed
uv run keel fetch
uv run keel simulate --years 1 --skip-within-cap

Two honest caveats are in the text because they were measured: keel fetch without a CDP key dies in AuthenticationError (tested), and keel simulate on default rules reports TRAIN MORE with the failing gates named — presented as the engine working, because it is.

Rest of the structure: condensed How-it-works (rules, rails, screening, confirm/autonomy, ships-inert, the tighter-stop→larger-position mechanic), an architecture sketch naming the three load-bearing places (keel/execution/guards.py, keel/agent.py, packages/keel-broker-* port + entry point), a documentation map, and an ending that routes questions to Discussions (now enabled) and contributions to CONTRIBUTING.md.

Moved, not deleted: deploying/upgrading releases, the paper-vs-live table and its interactions, and “how much money moves” now live in docs/operator-runbook.md (Part 2, verbatim in substance).

Tests (tests/test_readme.py, red first): honest result above the fold + cited record exists; quickstart commands verbatim incl. the CDP caveat and paper path; the three architecture locations named; README ends with Discussions/CONTRIBUTING routing; operator content present in the runbook and absent from the README (the anti-runnable-regression guard).

Gates: pytest 2794 passed / 1 skipped, ruff check clean, mypy clean.

chore(python): relax the floor to >=3.11, measured, with a CI leg to keep it true (#301)

Closes #283

requires-python = ">=3.14.4" said “the interpreter I develop on”, not “the interpreter the code needs” — and 3.14 is new enough that contributors who lack it fail to build and leave without opening an issue.

The measurement (the decision’s evidence, per the issue):

Pythonfull suite
3.132,788 passed / 1 skipped — identical to 3.14
3.122,788 passed / 1 skipped
3.112,788 passed / 1 skipped
3.10fails collection: ImportError: cannot import name 'assert_never' from 'typing'

Decision: nothing requires 3.14. The floor is set to >=3.11 — the lowest version the suite passes on — with typing.assert_never (3.11+) as the one concrete binding constraint. Development stays pinned to 3.14.4 via .python-version.

What changes

  • requires-python = ">=3.11" in all six distributions; uv.lock re-resolved.
  • ci.yml’s test job becomes a 3.11 + 3.14 matrix, so the floor stays a measured claim. Both legs stay inside the test job on purpose: the matrix fans out the required status context rather than renaming it, so every leg gates the merge — the same lesson the job’s own comments record for mypy (#268).
  • mypy python_version = "3.11" — check against the floor, not the dev pin.
  • README quickstart and CONTRIBUTING state 3.11 and the reason (“a contributor hitting the floor gets a clear message rather than a confusing resolver error” — low floor, stated plainly).
  • tests/test_python_floor.py (red first) pins the decision’s four halves: one floor in every distribution, the binding feature still imported (if assert_never ever disappears, the floor can drop — re-measure), the CI leg present, and the docs stating it.

Gates: pytest 2,798 passed / 1 skipped on both 3.14 and 3.11; ruff and mypy clean.

docs(conduct): adopt the Contributor Covenant, with a stance on religious disagreement (#302)

Closes #284

A project inviting international contributors around a religious topic needs a stated behavioural baseline before it needs anything else.

What

  • CODE_OF_CONDUCT.md: canonical Contributor Covenant text fetched from GitHub’s codes-of-conduct API (the same trusted-source trick as the Apache-2.0 LICENSE in #296 — not retyped), with the [INSERT CONTACT METHOD] placeholder filled with the maintainer’s address.
  • One project-specific addition, appended as its own section:
    • disagreement about a religious ruling is expected and legitimate — not disloyalty, not trolling, not a conduct problem;
    • disparaging a madhhab, a school, a scholar, or a contributor’s own practice is not — enforced exactly like any other harassment (“the Hanafi position on X is wrong” is an argument; “nobody serious follows that” is an attack);
    • the technical route for disagreement is a local attestation (keel assets attest) or the Compliance & classification Discussions category — never a pull-request thread, and never a remark about the person holding the other view.

Tests (tests/test_code_of_conduct.py, red first): all five Covenant section headings present (the file is the Covenant, not a bespoke policy); no [INSERT placeholder survives (the unfilled-template failure shape); a concrete reachable contact is named; and the religious-disagreement stance exists with its three required elements (madhhab line, expected-and-legitimate, local-attestation route).

Gates: pytest 2,798 passed / 1 skipped, ruff check clean, mypy clean.

docs(templates): issue forms with a distinct compliance-question route, and a PR template that asks the right questions (#303)

Closes #285

Templates do two jobs: extract what triage needs, and state the project’s standards at the moment of submission.

Issue forms (.github/ISSUE_TEMPLATE/):

  • bug_report.yml — demands the three facts a report is useless without: keel --version output (stamps commit and build kind), paper-vs-live (they share nothing), and the rail or rule involved (the address of the behaviour). Security warning up top.
  • feature_request.yml — problem / proposal / alternatives considered (this project records rejected alternatives on purpose), plus a scope checkbox separating mechanism changes from classification changes.
  • compliance_question.yml — the distinct route the issue asks for: “should X be treated this way” is neither a bug nor a feature, and triaging it as either gets it the wrong resolution. Asks for the asset/mechanism, sources being weighed, and what outcome would resolve it — including “a change to a default classification (needs sources, discussed before any PR)”.
  • config.yml — blank issues off; contact links route security to the private channel (../security/advisories/new + SECURITY.md note), questions to Discussions, and contributors to CONTRIBUTING.md.

PR template (.github/PULL_REQUEST_TEMPLATE.md): what & why, tests-first evidence (paste the red run), the three gates as checkboxes, and the load-bearing scope check: a checkbox that declares whether the PR touches a rail or a default classification — checked means it does, and then source + discussion come before review.

Tests (tests/test_issue_templates.py, red first): forms parse as GitHub issue forms; bug report carries version/mode/component fields; the compliance route exists and points at attestation + source; config.yml routes to SECURITY.md; the PR template names the gates, tests-first, and rail/classification.

Gates: pytest 2,807 passed / 1 skipped, ruff check clean, mypy clean.

docs(fiqh): the fiqh basis document — every encoded ruling, sourced (#288) (#317)

Closes #288.

What & why

A Muslim developer deciding whether to trust keel with money asks a question the code cannot answer by being read: WHAT fiqh does this machine enforce, and where did each ruling come from? The answers exist — scattered across guard comments, a KB the size of a bookshelf, and a handful of experiment records — auditable in principle, auditable by nobody who does not already know where to look.

This PR adds docs/fiqh-basis.md: the Shariah reasoning encoded in keel, stated ruling by ruling, each with its in-repo source — the curation screen (sector §28.4, backing §65.5/§67.2, pays_yield bare-holder semantics, wrapper §71.4a), rail 1 allowlist enforcement, rail 17 qabd §65.4 (Ayub quoted verbatim, tri-sourced §65.4 · §67.1 OIC 53/4-6 · §71.5 AAOIFI SS 18 3/5, 7-day TTL, entries-only, fails closed), rails 18/19 as charter, purification §65.9 and USDC-rewards §56.3 — plus what is attested versus computed, what keel deliberately does not decide, the known open questions (ATOM dilution, staking §65.14 contested, DOGE §86.4), and how to disagree. The remaining rails are marked PRUDENTIAL-not-fiqh with §65.6’s anti-scalping correction stated in the KB’s own words.

The document claims no scholarly review; that is deliberately #289’s still-open question.

README’s documentation map and CONTRIBUTING’s governance section each gain one link line.

Tests-first evidence

tests/test_fiqh_basis.py written first; the document did not exist.

Red run (uv run pytest tests/test_fiqh_basis.py -q):

FAILED tests/test_fiqh_basis.py::test_the_document_exists - AssertionError: d...
FAILED tests/test_fiqh_basis.py::test_the_boundary_sentence_is_stated_verbatim
FAILED tests/test_fiqh_basis.py::test_the_qabd_condition_is_pinned_three_sided
FAILED tests/test_fiqh_basis.py::test_the_attested_never_inferred_split_is_pinned_two_sided
FAILED tests/test_fiqh_basis.py::test_the_fails_closed_posture_is_pinned_two_sided
FAILED tests/test_fiqh_basis.py::test_the_riba_failure_wording_is_pinned_two_sided
FAILED tests/test_fiqh_basis.py::test_the_bare_holder_semantics_are_pinned_to_the_experiment_record
FAILED tests/test_fiqh_basis.py::test_rail_17s_seven_day_ttl_is_pinned_to_the_executor
FAILED tests/test_fiqh_basis.py::test_the_prudential_rails_are_separated_from_the_fiqh_rails_with_the_65_6_correction
FAILED tests/test_fiqh_basis.py::test_the_atom_dilution_open_question_is_stated_not_hidden
FAILED tests/test_fiqh_basis.py::test_every_kb_citation_resolves_to_a_source_file_in_the_repo
FAILED tests/test_fiqh_basis.py::test_the_disagreement_section_names_the_local_attestation_route
FAILED tests/test_fiqh_basis.py::test_the_readme_links_the_document - Asserti...
13 failed in 0.07s

Every failure is the assertion it was written to be (the _doc() helper returns empty for a missing file, so red is failures, not errors). After writing the document and the two link lines: 13 passed.

…truncated — full description in #317.

docs(review-path): the scholarly-review stance — not reviewed, path defined (#289) (#318)

What & why

Closes #289. Decides the scholarly-review question the honest way the issue demands: keel offers option 3, said out loud — no scholarly review has occurred — in the README’s first screen and in a new “Scholarly review status” section of docs/fiqh-basis.md. Alongside the disclaimer, the review path is fully defined (option 2’s machinery, documented as a path, not a claim): what a review will cover, what a reviewer is and is not endorsing, how a review is recorded as a dated addendum that can only ratchet from not-reviewed to reviewed-with-a-named-scope, and the outreach shortlist (IIUM, INCEIF, Durham, Islamic fintech practitioners) with a ready-to-send note. The approach itself is the operator’s action and is stated as not taken.

Tests-first evidence

tests/test_scholarly_review.py written first: red run showed 8 failed / 1 passed (the negative no-false-claims test passes trivially before any doc exists — it exists to guard afterwards). After the two insertions (README paragraph after the boundary blockquote; the status section between “How to disagree” and “Sources index”): 9 passed. The pinned no-review sentence — “No scholarly review of keel’s fiqh basis has occurred” — is asserted identically in the README first screen and the status section, and the negative test scans README/CONTRIBUTING/fiqh-basis for affirmative overstatement phrasings (“scholar-approved”, “reviewed and approved”, “certified”, “endorsed by”, …) so the claim can never quietly soften or inflate.

Gates
  • uv run ruff check keel tests packages — All checks passed!
  • uv run mypy — Success: no issues found in 234 source files
  • uv run pytest -q — 2832 passed, 1 skipped
Scope check
  • No rail, rule, or default classification is touched — documentation and pinning tests only.

docs(arabic): README.ar.md — the Arabic entry point (#290) (#319)

What & why

Closes #290. Adds README.ar.md — the Arabic entry point a large share of the intended audience reads in — with a two-sided language switcher (English README links it under the title; it links back). The page covers the first-screen content in Arabic: what keel is (compliance-engine lead), the honest measured result linking the experiment record, the not-a-fatwa-engine boundary (its English original quoted alongside so translation and source are checkable against each other), the no-scholarly-review stance (#289), the five-minute quickstart with commands verbatim (commands are language-neutral), a five-term glossary (الربا، القبض، العين، الدَّين، الغرر) in their established Islamic-finance renderings, the scope statement (the full docs remain English; this is the entry point, not a promise to translate everything), routing, and disclaimers.

On the acceptance item “terminology reviewed by a native speaker”: the Arabic was authored directly to the established renderings (not machine-translated), the glossary teaches the vocabulary the page uses, and the document itself invites terminology corrections via Discussions as a standing request — that is the honest form of review available to the repo today, and it is stated in the document.

Tests-first evidence

tests/test_arabic_readme.py written first: red run 9/9 failed (no README.ar.md). After authoring: 9 passed — pinning the RTL wrapper, two-sided switcher, Arabic boundary + English original, no-review stance + fiqh-basis link, honest result + record link + file existence, verbatim quickstart commands + CDP caveat, the five terms, the scope statement, and the disclaimers.

Gates
  • uv run ruff check keel tests packages — All checks passed!
  • uv run mypy — Success: no issues found in 235 source files
  • uv run pytest -q — 2841 passed, 1 skipped
Scope check
  • No rail, rule, or default classification is touched — documentation and pinning tests only.

ci(security): real scans, tokenless — dependabot, pip-audit over the lock, CodeQL (#291) (#320)

What & why

Part of #291 (the pre-launch gate’s code-quality-scans item, which the gate blocks on): the repo has never had static analysis or dependency scanning — SONAR_TOKEN/SNYK_TOKEN were names for secrets nobody created. This PR configures both classes of scan using what a public repository gets for free, on GITHUB_TOKEN alone:

  • .github/dependabot.yml — weekly, grouped updates for all six Python manifests (root + five packages/*) and the github-actions ecosystem. A manifest Dependabot doesn’t see is a distribution whose dependencies update silently.
  • .github/workflows/security.yml — a dependencies job that audits uv export of the locked set (pip-audit; the exact pinned versions deployments get), and a codeql job (Python) with security-events: write). Runs on push, PRs, and weekly — CVEs are published against already-pinned versions, not only against new commits. Deliberately NOT the testjob: themainruleset requires thetest` context, and a security finding is information about the state of the world, not a verdict on a proposed change — so these jobs surface findings without gating merges.
  • uv.lock — the audit’s first run (locally, verified end-to-end) surfaced PYSEC-2026-3552 in cryptography 49.0.0, fixed in 50.0.0; upgraded in the same commit so the scan ships green rather than red on day one. Full suite green on the upgrade.

tests/test_security_scans.py (red-first, 6 tests) pins: every manifest directory watched + schedules present, the audit reads the lock’s export with the six own-distributions excluded, CodeQL init+analyze on Python with the upload permission, the weekly schedule trigger, and — the honest part — that no scan step references any secret at all (SONAR_TOKEN/SNYK_TOKEN never existed; a scan that needs them is a scan that doesn’t run).

Tests-first evidence

Red run: 6/6 failed (no configs existed). Green: 6 passed, full suite 2847 passed / 1 skipped. The pip-audit step was verified end-to-end locally: found the cryptography advisory on the pre-upgrade lock, clean on the upgraded one.

Gates
  • uv run ruff check keel tests packages — All checks passed!
  • uv run mypy — Success: no issues found in 236 source files
  • uv run pytest -q — 2847 passed, 1 skipped
Scope check
  • No rail, rule, or default classification is touched — CI configuration, a dependency upgrade the new scan surfaced, and pinning tests.

chore(deps-dev): update uv-build requirement from <0.11.0,>=0.10.4 to >=0.10.4,<0.13.0 in /packages/keel-core in the python-dependencies group (#321)

Updates the requirements on uv-build to permit the latest version. Updates uv-build to 0.12.4

Release notes

Sourced from uv-build's releases.

0.12.4

Release Notes

Released on 2026-08-13.

Enhancements

  • Prefer post-quantum key exchange and enable opt-in TLS diagnostics (#21054)
  • Accept whitespace before versions in noncompliant wildcard comparisons such as Requires-Python: >= 3.5.* (#21012)
  • Report a specific error when a PEP 723 closing tag contains trailing whitespace or other content (#20944)
  • Omit source-span carets from diagnostics for empty PEP 508 requirements (#21094)

Preview features

  • Add uv check --no-install-project and respect UV_NO_INSTALL_PROJECT to install dependencies without building or installing the project (#21085)
  • Make the ty subprocess invoked by uv check honor uv's color and progress settings, including quiet mode (#21086)

Performance

  • Speed up resolutions with long runs of unavailable package versions by coalescing gaps in the resolver's version ranges (#20804)
  • Speed up Simple API parsing by deserializing PyPI and Pyx file metadata directly (#21041)

Bug fixes

  • Use windowed pythonw.exe launchers for virtual environments created from managed Python minor-version links (#19235)
  • Allow uv lock to proceed when .venv is an unusable project environment (#21068)
  • Respect fork-strategy when ordering forks created from environments or existing lockfile resolution-markers (#21000)
  • Preserve consecutive wildcard Python minor-version exclusions such as !=3.11.*, !=3.12.* in uv.lock (#21045)
  • Preserve inline comments on the final item in dependency arrays when uv add updates it (#21008)
  • Recover from stale base-interpreter cache metadata when an existing virtual environment exposes a version mismatch (#21073)
  • Prevent interpreter cache reuse across different PYTHONEXECUTABLE and __PYVENV_LAUNCHER__ overrides (#21075)
  • …truncated — full description in #321.

    chore(deps-dev): update uv-build requirement from <0.11.0,>=0.10.4 to >=0.10.4,<0.13.0 in /packages/keel-broker-api in the python-dependencies group (#322)

    Updates the requirements on uv-build to permit the latest version. Updates uv-build to 0.12.4

    Release notes

    Sourced from uv-build's releases.

    0.12.4

    Release Notes

    Released on 2026-08-13.

    Enhancements

    • Prefer post-quantum key exchange and enable opt-in TLS diagnostics (#21054)
    • Accept whitespace before versions in noncompliant wildcard comparisons such as Requires-Python: >= 3.5.* (#21012)
    • Report a specific error when a PEP 723 closing tag contains trailing whitespace or other content (#20944)
    • Omit source-span carets from diagnostics for empty PEP 508 requirements (#21094)

    Preview features

    • Add uv check --no-install-project and respect UV_NO_INSTALL_PROJECT to install dependencies without building or installing the project (#21085)
    • Make the ty subprocess invoked by uv check honor uv's color and progress settings, including quiet mode (#21086)

    Performance

    • Speed up resolutions with long runs of unavailable package versions by coalescing gaps in the resolver's version ranges (#20804)
    • Speed up Simple API parsing by deserializing PyPI and Pyx file metadata directly (#21041)

    Bug fixes

    • Use windowed pythonw.exe launchers for virtual environments created from managed Python minor-version links (#19235)
    • Allow uv lock to proceed when .venv is an unusable project environment (#21068)
    • Respect fork-strategy when ordering forks created from environments or existing lockfile resolution-markers (#21000)
    • Preserve consecutive wildcard Python minor-version exclusions such as !=3.11.*, !=3.12.* in uv.lock (#21045)
    • Preserve inline comments on the final item in dependency arrays when uv add updates it (#21008)
    • Recover from stale base-interpreter cache metadata when an existing virtual environment exposes a version mismatch (#21073)
    • Prevent interpreter cache reuse across different PYTHONEXECUTABLE and __PYVENV_LAUNCHER__ overrides (#21075)
    • …truncated — full description in #322.

      chore(deps-dev): update uv-build requirement from <0.11.0,>=0.10.4 to >=0.10.4,<0.13.0 in /packages/keel-broker-fake in the python-dependencies group (#323)

      Updates the requirements on uv-build to permit the latest version. Updates uv-build to 0.12.4

      Release notes

      Sourced from uv-build's releases.

      0.12.4

      Release Notes

      Released on 2026-08-13.

      Enhancements

      • Prefer post-quantum key exchange and enable opt-in TLS diagnostics (#21054)
      • Accept whitespace before versions in noncompliant wildcard comparisons such as Requires-Python: >= 3.5.* (#21012)
      • Report a specific error when a PEP 723 closing tag contains trailing whitespace or other content (#20944)
      • Omit source-span carets from diagnostics for empty PEP 508 requirements (#21094)

      Preview features

      • Add uv check --no-install-project and respect UV_NO_INSTALL_PROJECT to install dependencies without building or installing the project (#21085)
      • Make the ty subprocess invoked by uv check honor uv's color and progress settings, including quiet mode (#21086)

      Performance

      • Speed up resolutions with long runs of unavailable package versions by coalescing gaps in the resolver's version ranges (#20804)
      • Speed up Simple API parsing by deserializing PyPI and Pyx file metadata directly (#21041)

      Bug fixes

      • Use windowed pythonw.exe launchers for virtual environments created from managed Python minor-version links (#19235)
      • Allow uv lock to proceed when .venv is an unusable project environment (#21068)
      • Respect fork-strategy when ordering forks created from environments or existing lockfile resolution-markers (#21000)
      • Preserve consecutive wildcard Python minor-version exclusions such as !=3.11.*, !=3.12.* in uv.lock (#21045)
      • Preserve inline comments on the final item in dependency arrays when uv add updates it (#21008)
      • Recover from stale base-interpreter cache metadata when an existing virtual environment exposes a version mismatch (#21073)
      • Prevent interpreter cache reuse across different PYTHONEXECUTABLE and __PYVENV_LAUNCHER__ overrides (#21075)
      • …truncated — full description in #323.

        chore(deps-dev): update uv-build requirement from <0.11.0,>=0.10.4 to >=0.10.4,<0.13.0 in /packages/keel-broker-robinhood in the python-dependencies group (#324)

        Updates the requirements on uv-build to permit the latest version. Updates uv-build to 0.12.4

        Release notes

        Sourced from uv-build's releases.

        0.12.4

        Release Notes

        Released on 2026-08-13.

        Enhancements

        • Prefer post-quantum key exchange and enable opt-in TLS diagnostics (#21054)
        • Accept whitespace before versions in noncompliant wildcard comparisons such as Requires-Python: >= 3.5.* (#21012)
        • Report a specific error when a PEP 723 closing tag contains trailing whitespace or other content (#20944)
        • Omit source-span carets from diagnostics for empty PEP 508 requirements (#21094)

        Preview features

        • Add uv check --no-install-project and respect UV_NO_INSTALL_PROJECT to install dependencies without building or installing the project (#21085)
        • Make the ty subprocess invoked by uv check honor uv's color and progress settings, including quiet mode (#21086)

        Performance

        • Speed up resolutions with long runs of unavailable package versions by coalescing gaps in the resolver's version ranges (#20804)
        • Speed up Simple API parsing by deserializing PyPI and Pyx file metadata directly (#21041)

        Bug fixes

        • Use windowed pythonw.exe launchers for virtual environments created from managed Python minor-version links (#19235)
        • Allow uv lock to proceed when .venv is an unusable project environment (#21068)
        • Respect fork-strategy when ordering forks created from environments or existing lockfile resolution-markers (#21000)
        • Preserve consecutive wildcard Python minor-version exclusions such as !=3.11.*, !=3.12.* in uv.lock (#21045)
        • Preserve inline comments on the final item in dependency arrays when uv add updates it (#21008)
        • Recover from stale base-interpreter cache metadata when an existing virtual environment exposes a version mismatch (#21073)
        • Prevent interpreter cache reuse across different PYTHONEXECUTABLE and __PYVENV_LAUNCHER__ overrides (#21075)
        • …truncated — full description in #324.

          chore(deps-dev): update uv-build requirement from <0.11.0,>=0.10.4 to >=0.10.4,<0.13.0 in /packages/keel-broker-coinbase in the python-dependencies group (#325)

          Updates the requirements on uv-build to permit the latest version. Updates uv-build to 0.12.4

          Release notes

          Sourced from uv-build's releases.

          0.12.4

          Release Notes

          Released on 2026-08-13.

          Enhancements

          • Prefer post-quantum key exchange and enable opt-in TLS diagnostics (#21054)
          • Accept whitespace before versions in noncompliant wildcard comparisons such as Requires-Python: >= 3.5.* (#21012)
          • Report a specific error when a PEP 723 closing tag contains trailing whitespace or other content (#20944)
          • Omit source-span carets from diagnostics for empty PEP 508 requirements (#21094)

          Preview features

          • Add uv check --no-install-project and respect UV_NO_INSTALL_PROJECT to install dependencies without building or installing the project (#21085)
          • Make the ty subprocess invoked by uv check honor uv's color and progress settings, including quiet mode (#21086)

          Performance

          • Speed up resolutions with long runs of unavailable package versions by coalescing gaps in the resolver's version ranges (#20804)
          • Speed up Simple API parsing by deserializing PyPI and Pyx file metadata directly (#21041)

          Bug fixes

          • Use windowed pythonw.exe launchers for virtual environments created from managed Python minor-version links (#19235)
          • Allow uv lock to proceed when .venv is an unusable project environment (#21068)
          • Respect fork-strategy when ordering forks created from environments or existing lockfile resolution-markers (#21000)
          • Preserve consecutive wildcard Python minor-version exclusions such as !=3.11.*, !=3.12.* in uv.lock (#21045)
          • Preserve inline comments on the final item in dependency arrays when uv add updates it (#21008)
          • Recover from stale base-interpreter cache metadata when an existing virtual environment exposes a version mismatch (#21073)
          • Prevent interpreter cache reuse across different PYTHONEXECUTABLE and __PYVENV_LAUNCHER__ overrides (#21075)
          • …truncated — full description in #325.

            chore(deps-dev): update uv-build requirement from <0.11.0,>=0.10.4 to >=0.10.4,<0.13.0 in the python-dependencies group (#326)

            Updates the requirements on uv-build to permit the latest version. Updates uv-build to 0.12.4

            Release notes

            Sourced from uv-build's releases.

            0.12.4

            Release Notes

            Released on 2026-08-13.

            Enhancements

            • Prefer post-quantum key exchange and enable opt-in TLS diagnostics (#21054)
            • Accept whitespace before versions in noncompliant wildcard comparisons such as Requires-Python: >= 3.5.* (#21012)
            • Report a specific error when a PEP 723 closing tag contains trailing whitespace or other content (#20944)
            • Omit source-span carets from diagnostics for empty PEP 508 requirements (#21094)

            Preview features

            • Add uv check --no-install-project and respect UV_NO_INSTALL_PROJECT to install dependencies without building or installing the project (#21085)
            • Make the ty subprocess invoked by uv check honor uv's color and progress settings, including quiet mode (#21086)

            Performance

            • Speed up resolutions with long runs of unavailable package versions by coalescing gaps in the resolver's version ranges (#20804)
            • Speed up Simple API parsing by deserializing PyPI and Pyx file metadata directly (#21041)

            Bug fixes

            • Use windowed pythonw.exe launchers for virtual environments created from managed Python minor-version links (#19235)
            • Allow uv lock to proceed when .venv is an unusable project environment (#21068)
            • Respect fork-strategy when ordering forks created from environments or existing lockfile resolution-markers (#21000)
            • Preserve consecutive wildcard Python minor-version exclusions such as !=3.11.*, !=3.12.* in uv.lock (#21045)
            • Preserve inline comments on the final item in dependency arrays when uv add updates it (#21008)
            • Recover from stale base-interpreter cache metadata when an existing virtual environment exposes a version mismatch (#21073)
            • Prevent interpreter cache reuse across different PYTHONEXECUTABLE and __PYVENV_LAUNCHER__ overrides (#21075)
            • …truncated — full description in #326.

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

              Bumps the github-actions group with 3 updates: actions/checkout, astral-sh/setup-uv and github/codeql-action.

              Updates actions/checkout from 4 to 7

              Release notes

              Sourced from actions/checkout's releases.

              v7.0.0

              What's Changed

              New Contributors

              Full Changelog: https://github.com/actions/checkout/compare/v6.0.3...v7.0.0

              v6.1.0

              What's Changed

              • [BREAKING] backport allow-unsafe-pr-checkout to v6 by @​aiqiaoy in actions/checkout#2500
              • …truncated — full description in #327.

                docs(launch): the pre-launch gate and the announcement plan (#291) (#328)

                What & why

                Closes #291 (with #320, the scans half, already merged). docs/launch.md is the gate the issue asks for — nothing is announced until every box below is ticked — with every box ticked and evidence-linked: Phase 6 (licence/discoverability/positioning), Phase 7 (README, CONTRIBUTING, CoC, templates, Discussions, good-first-issues), the fiqh basis (#317), the review-path stance (#318), the Arabic entry point (#319), CI green on main, the tokenless scans actually configured (#320), and the maintainer-response commitment stated in CONTRIBUTING.md. Below the gate: the audience in the issue’s order (Islamic fintech practitioner networks → r/islamicfinance + Muslim dev communities → IIUM/INCEIF/Durham → only then HN/Reddit/Lobsters), what the announcement must say — the measured result in the post, with the real numbers — and a ready-to-adapt draft. Plus the explicit non-goal (not for stars).

                Two corrections of the issue’s own text, made in the document: the measured result is 0 of 90 and 0 of 82 under production-faithful execution (the “0 of 20” in the issue misremembers the earlier 0-of-19 hourly record), and the scans item is satisfied tokenless (#320) rather than by waiting for SONAR_TOKEN/SNYK_TOKEN. One gate item needed a repo change: CONTRIBUTING.md now carries an honest-for-one-person response commitment (triage 3 days, first PR review a week, security routed to SECURITY.md’s SLA).

                The announcement itself is deliberately NOT part of this PR — the gate arms it; walking through the door is a human action.

                Tests-first evidence

                tests/test_launch_gate.py written first: red 8/8 (no doc). Green: 8 passed — pinning the gate rule verbatim, each gate item named, the real numbers + the record link (path-aware: the doc lives in docs/, the link is doc-relative) + file existence, the audience ORDER by position, the non-goal, the draft’s load-bearing content (compliance-engine lead, 0 of 90, not a fatwa engine, No scholarly review), CONTRIBUTING’s solo-maintainer commitment, and the README documentation-map link.

                Gates
                • uv run ruff check keel tests packages — All checks passed!
                • uv run mypy — Success: no issues found in 237 source files
                • uv run pytest -q — 2856 passed, 1 skipped
                Scope check
                • No rail, rule, or default classification is touched — documentation and pinning tests only.

                chore(release): 0.8.0 (#329)

                What & why

                Version bump across all six distributions. Minor (0.8.0), not patch, because two changes since v0.7.1 are user-facing: the supported-Python floor widened to >=3.11 (measured; CI now runs a 3.11 leg to keep it true, #301), and every distribution now carries Apache-2.0 as PEP 639 licence metadata with py.typed shipped (#264 and the Phase 6 licence work).

                What else is in the arc this release collects: the open-source readiness docs (README rewritten for the stranger #300, docs/fiqh-basis.md #317, the scholarly-review stance #318, the Arabic entry point #319, docs/launch.md #328, Contributor Covenant #302, issue/PR templates #303, SECURITY.md #298, governance #297), the tokenless security scans with Dependabot + weekly pip-audit over the frozen lock + CodeQL (#320, which also fixed PYSEC-2026-3552 by moving cryptography to 50.0.0), the CI matrix (#301) and the mypy gate (#268), plus the dependency/action updates Dependabot has merged since.

                No rail, rule, or screening behaviour changed since v0.7.1 — the diff is packaging, metadata, docs, CI, and the cryptography upgrade the new audit surfaced.

                Tests-first evidence

                tests/test_packaging.py and tests/test_python_floor.py already pin the invariants this bump touches (sibling == pins and the floor); after the bump and uv lock: 2856 passed, 1 skipped; ruff clean; mypy clean; keel --version reports 0.8.0.

                Gates
                • uv run ruff check keel tests packages — All checks passed!
                • uv run mypy — Success: no issues found in 237 source files
                • uv run pytest -q — 2856 passed, 1 skipped
                Scope check
                • Version numbers and uv.lock only; no code, rails, rules, or classifications touched.

v0.7.1

Voir cette version sur GitHub ↗

Built from 02bc86d90b67. Version binds to this hash: keel --version reports keel 0.7.1+02bc86d90b67 [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.7.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

docs(experiments): three rules, 24 assets, zero free parameters — the viable intersection is empty (#252)

Closes #251.

Measures every signal rule the codebase ships at its shipped constructor defaults, across the whole 24-asset hourly corpus, at three fee levels. 270 backtests, 0 errors. Documentation only — no code, no config, no rule status, no version bump.

Verdict

There is no asset-rule combination that clears the promotion trade floor with an edge surviving even the cheapest fee we can reach — and the three rules fail for three unrelated reasons.

rulen≥100∧ gross>1∧ net@0.6%>1∧ net@1.2%>1the lever
turtle_breakout21/2471 (ZEC)0cost
pullback_continuation4/24100signal
rsi_meanrev0/24——0sample size
total25/72810

The single survivor, ZEC-turtle, is then eliminated by a temporal decomposition: three consecutive losing years (2022–24) with 92.7% of lifetime PnL compressed into 2025–26.

Two earlier conclusions this supersedes

1. rsi_meanrev does not lack gross edge. #248 recorded that it “has no gross edge at all” on the strength of BTC at 0.775. Across all 24 assets it has the best gross distribution of the three rules — median gross PF 1.1631, against turtle’s 0.9892 and pullback’s 0.9292 over the same universe. BTC sits near the bottom of its distribution. The rule does not lose; it is not observable — 0 of 24 assets reach n≥100, median n=38. #248’s figures came from widened parameters (oversold 25–35), not defaults, so both documents are right about what they measured; the error was generalising from one asset.

2. The 864-trial sweep winner is not overfit. #248 framed it as selection. Arm B evaluates that config on the 18 assets it was never scored on: in-sample mean net PF 0.6335 → out-of-sample 0.6346, gross 1.5317. A clean three-decimal replication across disjoint assets. That is a harder result than overfitting — overfitting has a methodology fix; a stable measurement of 0.63 does not.

pullback_continuation, measured for the first time

In RULE_REGISTRY since it was written, never backtested. Median gross 0.929, and a fee collapse qualitatively unlike turtle’s — PAXG-USDT 1.097 → 0.001 at maker. The cause is in the win rates: 52–75% wins at PF≈1.0 is measured_1to1 taking small wins against small losses, so a 1.2% round trip does not reduce the edge, it erases it. High win rate is the symptom of maximal fee fragility here, not a strength.

Why the temporal probe is reported and never gated

Three reasons in §6, the third decisive: ZEC under pullback_continuation is the only combination in the study with no losing complete year (1.06/1.17/1.16/1.13) — at gross PF 0.875. Perfectly stationary at losing slightly, reliably. A stationarity gate would wave it through while rejecting ZEC-turtle, which at least made money gross. The probe is also underpowered (passes zero-edge strategies ~50% of the time at four buckets) and fragile to bucket boundaries (excluding partial 2021/2026 flips the verdict for 4 of 21 assets, including FET).

The principled instrument already exists and is now live: #247 wired g4_pbo_gate into can_promote, where pbo=None blocks. It is deployed and nothing feeds it. Building a weaker annual-bucket heuristic beside an unfed rigorous one is the wrong order of work.

Honesty notes recorded in the document rather than omitted
  • The pre-registration lived in the dispatch brief, not the script docstring — weaker than every other harness here. The script now says so at the top instead of presenting the reconstruction as though it had always been there.
  • The tail and regime probes were written after seeing Arm A. Post-hoc by construction, which is exactly why they are diagnostics.
  • Four failed runs preceded the successful one, caused by piping into tail (buffers until EOF, then dies with the shell) and by pkill -f not reaping multiprocessing children. Both produced confident status reports that were false. §7.
  • 27 combinations ran on 0.6.1 and the rest on 0.7.0 after #247 deployed mid-run. Version independence was checked, not assumed: BTC-USD reproduces to six decimal places across the boundary.
What this leaves open

One lead, and it is cheap: does rsi_meanrev’s gross edge survive being made to fire more often? #248’s 108-cell diagnostic widened the thresholds and found no net-viable cell, but never asked whether widening preserved the gross edge or simply bought trades by accepting worse setups. Different findings, different consequences, and the data separating them was never computed.

A limit-order queue simulator has no target: its measured prize was one asset with ~8bp of headroom below maker, and §6 removes that asset.

Ledger

Two rows — Arm A (a_priori) and Arm B (fitted, since the params were selected even though this evaluation selects nothing further). Both diagnostic_only, both series_missing: true so the CSCV matrix refuses them. verify_chain clean at 82 rows.

docs(experiments): rsi_meanrev’s edge is selectivity, not alpha — the last live lead closes (#255)

Closes #253. Answers the single open question left by #252. Documentation only — the simulator defect found along the way is filed as #254 and deliberately not fixed here.

Result

rsi_meanrev reaches the promotion floor trivially — 21 of 24 assets at oversold=40, so the pre-declared conditional arm never fired — and has no edge when it gets there.

gross PF at oversold=20 (median n=38)  : median 1.1631    gross>1: 14/24
gross PF at every cell with n>=100     : median 0.8938    gross>1: 11/76
net > 1.0 at n>=100, at ANY fee        : 0 of 76   (including 0.6% maker)

The edge evaporates exactly when the rule becomes measurable — as a level shift across the floor, not a gradual decline. The 1.1631 that made this look like the best of the three rules in #252 is what n=38 looks like. All 11 gross-positive cells at n≥100 are listed in the document (enumeration, not selection); the best is 1.215 gross and 0.678 at maker.

Hypothesis (b) from the pre-registration, confirmed: the rule is unpromotable by construction.

Pre-registration in the file this time

§7 of #252 recorded against itself that its declaration lived in a dispatch brief rather than the script. This one’s is in the docstring, written before the run — including the conditional arm’s trigger, threshold and reporting rule, so the widening could not become a post-hoc rescue.

Two method notes against ourselves

1. The declared statistic was underpowered for its own question. The slope is reported as declared — mean −0.0386 per +100 trades, median −0.0197, 15/24 negative — and then flagged: the relationship is a threshold effect at the floor, not linear, and two barely-trading assets (PAXG-USD n 12→145, WLD n 1→71) supply most of its 0.35 stdev. The partition on the pre-declared n≥100 floor is the decisive reading. Reporting the stronger analysis while omitting that it wasn’t the declared one is the exact failure pre-registration exists to prevent, so both are in.

2. The monotonicity assumption is violated on 3 of 24 assets — and the cause is not the rule.

The simulator defect (#254)

UNI-USD’s trade count collapses 309 → 9 between oversold 30 and 35 — loosening the entry threshold reduced trades 34×. Diagnosis: 9 closed trades, 0 open, last exit 2021-11-15, i.e. the rule stopped detecting for ~40,000 bars.

if position is None and pending is not None:
    entry_touched = _touches(candle, pending.entry)
    if not entry_touched:
        continue          # pending persists — forever

A pending setup never expires. If price drifts away so neither entry nor stop is touched again, rule.detect() is never called for the rest of the series. The strategy freezes silently, and a frozen backtest is indistinguishable from a selective one — which is how it survived unnoticed.

It biases toward low trade counts, i.e. toward exactly the “unmeasurable” verdicts these documents have been issuing, and it diverges from production semantics (the live path re-detects each cycle).

#252’s headline results were checked and are clean — the three sub-floor turtle assets each trade to within 1–2 days of their series end, so their low counts are genuine short history. What is not established is the effect on lower-n cells of earlier sweeps; that needs a re-run, which is why #254 exists rather than a guess here.

What this closes

Every signal rule the codebase ships has now been measured at defaults across 24 assets and along its own frequency axis. There is no asset-rule-parameter combination that is simultaneously measurable and profitable at any reachable fee.

Priority order for what remains is in §6: fix #254 first (it’s a correctness issue in the instrument every one of these conclusions was produced with), then feed the PBO/CSCV gate that #247 deployed and nothing supplies. The maker-execution and queue-simulation work has no target — #252 removed its only candidate and this removes the rule that might have supplied another.

Ledger

One row, decision: rejected rather than diagnostic_only — this trial made a decision and should count toward the multiple-testing N. verify_chain clean at 83 rows.

fix(strategy): re-detect while flat, so an unfilled setup cannot switch the detector off (#256)

Closes #254. A correctness defect in the instrument every experiment in docs/experiments/ was produced with.

The defect

backtest() set pending and, if the entry was never touched, carried it forward unchanged. The only path that cleared it without a fill was the stop being touched first. So a setup whose entry and stop were both never revisited pinned pending for the rest of the series: the position is None and pending is None branch never ran again, rule.detect() was never called again, and the engine silently switched its own detector off.

Nothing errored, nothing warned. The output was indistinguishable from a rule that simply found no further setups — a frozen backtest looks exactly like a selective one, which is how it survived every experiment to date.

Measured

rsi_meanrev on UNI-USD, hourly, everything at defaults except oversold:

oversold=30  ->  309 closed trades, last exit 2026-08-04   (trades throughout)
oversold=35  ->    9 closed trades, last exit 2021-11-15   (dead for ~40,000 bars)

Loosening the entry threshold cut the trade count 34×. Also visible as non-monotonic n on BTC-USD (181 → 80 between oversold 30 and 35) and AAVE-USD.

Why re-detect rather than “expire after N bars”

Because N never existed in production. strategy/engine.py::evaluate calls rule.detect() once per cycle unconditionally and carries no pending-setup state between cycles — an unexecuted setup is simply re-derived from fresh data next cycle. Expiry would have been a new tunable invented for the simulator; re-detection makes the simulator match the live path it is supposed to be modelling. This was checked against the live code rather than assumed.

No lookahead is introduced. The fill attempt still happens first, and the re-detect uses the same candles[: i + 1] window the flat branch would have used, so a setup derived on bar i can still only fill on bar i+1 or later.

One-position-at-a-time is unaffected. That is enforced by the open position check, not the pending one, and it is unchanged — TestNoOverlap passes untouched. Two now-stale docstrings that claimed detect() runs only when pending is None have been corrected.

Verification
  • 2714 passed, 1 skipped (2712 before + 2 new).
  • Two regression tests from the UNI-USD case, both verified to fail on the unfixed code (detect_calls 1 vs 3) — reverted the fix, ran them, confirmed red, restored.
  • The committed BTC daily baseline fixture is unchanged. No freeze occurs on that corpus, which usefully bounds the blast radius: this only moves series where a setup went unrevisited.
  • ruff check keel tests packages clean.
Blast radius on the research corpus

Non-trivial, and being re-measured now. Early rows from the #252 re-run against fixed code, Arm B (entry_lookback=336):

turtle NEAR-USD    n  73 ->  74    gross 1.079 -> 0.899
turtle TON-USD     n   9 ->   9    gross 3.751 -> 2.873
turtle PAXG-USD    n  26 ->  26    gross 2.936 -> 2.659
turtle ADA-USD     n  85 ->  89    gross 1.598 -> 1.631
10 of the first 13 combinations changed

Trade counts move by only ±1–4, but gross profit factors move materially on the low-n assets. The full 90-combination re-run of #252 and the 96-cell re-run of #255 follow in a separate PR, since restating those documents’ numbers is a documentation change and this is an engine change.

fix(strategy): fill entries at the next bar’s open, as production’s market orders do (#258)

Closes #257. Option A of the fork raised on that issue, per your call: match the live path rather than change money-moving code to accommodate a rule already measured as alpha-deficient.

The divergence

The simulator held a Setup until a later bar’s range touched its entry, then filled at that level. Production does not do that:

order_type="market", limit_price=None, expected_fill=intent.entry

Live never rests an order at Setup.entry and never waits for price to come to it. When engine.evaluate emits a signal and the rails pass, the executor buys at market that cycle.

The old model therefore gave the backtest two things the live box does not have:

  1. Free optionality on the entry price — a setup only became a trade if the market offered the chosen level, so unfavourable entries were silently declined.
  2. Unbounded patience — any later bar touching the level filled there.

Both flatter results, and the bias runs opposite to #254’s, which suppressed trades.

The change

Entries fill at candles[i+1].open plus slippage — the first price obtainable once the signal exists, and the earliest fill involving no lookahead. Setup.entry becomes informational, exactly as expected_fill is live; risk is measured from the achieved fill against the setup’s stop, never from the quoted entry.

This subsumes #254. Every pending now resolves on the very next bar, so the state that froze the detector is unreachable rather than merely handled.

It also removes the wrinkle in #247’s fee justification. “Touching entry is marketable, therefore taker” held for a breakout entry above the market but not for a mean-reversion entry below it, which touching would make a maker fill. With every entry a market order, taker is unconditionally correct.

The consequence, named rather than hidden

A rule encoding a confirmation condition in its entry price no longer gets one. pullback_continuation sets entry = signal_candle.high + buffer_ticks precisely to demand follow-through, and a market fill takes trades it meant to decline.

That is not introduced here — it is what the live box already does, and modelling it faithfully is the point. Option B (make the executor honour a stop/limit entry) is the alternative and stays open on #257; it was declined because it changes live order routing to rescue a rule we have already measured as alpha-deficient.

Tests
  • TestIntrabarResolutionEntryVsStop deleted — that ambiguity only arises for an entry seeking a level and is now unreachable. A note in its place says so. _resolve_order is still exercised for stop-vs-target, which remains real, including on the fill bar.
  • #254’s regression class restated as TestEntryFillsAtNextBarOpen: a setup whose entry is never touched now fills anyway, and detect_calls == 1 rather than #256’s 3 — the number that distinguishes the two fill models.
  • 2711 passed, 1 skipped; ruff check keel tests packages clean.
Golden regenerated (second time ever; #247 was the first)
profit_factor   1.269371 -> 1.269287
expectancy      692.0773 -> 691.9480
max_drawdown    14220.67 -> 14222.05
n_trades  13    win_rate 0.4615    (both UNCHANGED)

#247’s reasoning for why n_trades held does not carry over — this is a fill-logic change, so the count was free to move and simply didn’t. It held because turtle_breakout enters at the close and 24/7 crypto barely gaps, so both models pick nearly the same price on this corpus. Read those deltas as “this corpus is insensitive to the fill model”, not as “the fill model barely matters” — the daily BTC baseline never exercises an offset entry. The docstring now says exactly that, so the small numbers can’t be misread later as evidence the change was cosmetic.

Pre-registered prediction for the re-run (in flight)

Recorded before results, so it can be wrong: n rises (every signal becomes a trade, including the gap-away entries previously declined) and gross PF falls (the declined entries were disproportionately the unfavourable ones). Largest effect expected on pullback_continuation, whose entry is offset above the signal bar; smallest on turtle/rsi, which enter at the close.

docs(experiments): restate #252 and #255 on a production-faithful engine, and record why the defects were invisible (#261)

Both experiments re-run under #256 (pending setups no longer freeze the detector) and #258 (entries fill at the next bar’s open, as production’s market orders do). Same designs, same combinations, same fee grid and slippage pin — only the engine differs.

Every conclusion survives except one

ZEC-turtle no longer clears the maker line — 1.034 → 0.968. That collapses #252 §6’s architecture: its three-probe narrative (fee curve, tail-sensitivity, temporal — each catching what the others miss, with ZEC as the through-line) describes a survivor the faithful engine never produces.

The replacement is shorter and worse for the library: the viable quadrant is empty at every reachable fee — 0 of 90 in #252, 0 of 82 in #255 — with nothing needing three gates to die. The probes aren’t worthless; they’re repositioned from “what killed the last survivor” to “forensics explaining an artifact a defective engine manufactured.”

#255 strengthens: the level shift widens from 1.1631 → 0.8938 to 1.1251 → 0.8396, gross-positive cells at the floor nearly halve (11/76 → 6/82), and the 34× UNI-USD monotonicity anomaly that exposed both defects is structurally gone (3 assets → 0) — an independent check on the fixes, not a restatement.

The two defects pushed opposite ways

#256 suppressed opportunity; #258 flattered execution. Correcting both didn’t add noise — it moved everything one way:

trade counts rose in     87 of 90
gross profit factors fell in 69 of 90

Every prior conclusion in docs/experiments/ was measured on an engine simultaneously too pessimistic about how often a rule fires and too optimistic about what it pays to get in.

Arm B, restated on a single engine
in-sample  0.5770   out-of-sample 0.5427   gap 0.034

#252 reported 0.6335 vs 0.6346 — a three-decimal agreement across one engine each, so partly luck. Conclusion unchanged and now engine-consistent: the sweep winner is not overfit, it is stably unprofitable.

§5 is the part worth reading

Neither defect was found by looking for defects, and neither was findable by the means we had. 2,712 tests passed throughout. #256 surfaced only from a 34× non-monotonicity visible because a grid happened to sweep wide enough; #258 only from asking why #256’s fix reduced trade counts.

The reason tests couldn’t catch either generalises:

A frozen backtest and a highly selective strategy produce identical-looking output. So do a patient limit fill and a lucky one. Nothing about n, win rate or profit factor distinguishes “the rule declined to fire” from “the engine stopped asking.”

Adding tests doesn’t fix that — a test asserts behaviour someone already imagined. The document recommends invariants the engine reports about itself: a dead-tail warning (#256 had a 4.7-year dead tail in plain sight of every run), intent-vs-fill divergence logging (#260 — the order row already stores both numbers and nothing compares them), and cost anchored to output (#247, shipped, and the model for the rest).

Also corrected: a distinction that would have misled future work

pullback_continuation’s median n doubled (58 → 124) when the market fill removed its offset entry. That is not evidence offset entries generate alpha — both sides lose money gross (0.9219 filtered, 0.7736 unfiltered). The filter separated bad from worse. §3.1 says so explicitly so this dataset can’t later be cited the other way.

Method

Annotates rather than rewrites, per the convention #247 set — “past numbers were real outputs of the code as it stood; they are annotated by this change, not restated.” Both documents keep their figures and carry a banner pointing here, so each remains a true record of what its engine produced.

Two ledger rows (diagnostic_only for the intersection restatement, rejected for the rsi grid, which decides against the rule); verify_chain clean at 85. Both re-run harnesses committed. 2711 passed, 1 skipped.

feat(engine): two observable invariants, because neither past defect was findable by testing (#262)

The operational takeaway from #261 §5, implemented. Neither #254 nor #257 was found by looking for defects — #254 surfaced from a 34× non-monotonic trade count, #257 from asking why #254’s fix reduced trade counts — and 2,712 tests passed throughout both.

The reason tests couldn’t catch either generalises: a frozen backtest and a highly selective strategy produce identical output. So do a patient limit fill and a lucky one. Adding tests doesn’t help, because a test asserts a behaviour someone already imagined. These two make each defect class announce itself in the ordinary output of an ordinary run. Neither touches order routing.

1. Pending lifespan invariant — keel/strategy/backtest.py

Asserts a pending setup is never carried across more than one bar. Since #257 the fill is unconditional, so the correct value is exactly 1 for every rule on every series — checked rather than thresholded, no per-asset tuning, no false-positive mode. On UNI-USD under #254 this counter would have read ~40,000.

The dead-tail heuristic was considered and rejected. #257 made the freeze structurally impossible, so a dead tail now only ever means the rule genuinely stopped firing — a regime change, a threshold too strict for recent volatility. A warning on “trades stopped >15% before the corpus ends” would fire exclusively on legitimate runs, and the first three false alarms teach an operator to ignore the fourth. That costs more than no alert.

If resting entry orders return (#260 Option B), the bound stops being 1 — and the value it becomes IS the cancel/replace policy, stated in one place.

2. Intent divergence log — keel/execution/executor.py

The executor already persisted the rule’s intended entry (expected_fill) and the achieved fill (actual_fill) on the same order row, and nothing compared them. That missing subtraction is exactly how #257 stayed invisible. _log_intent_divergence now reports signed basis points on every fill.

  • Unconditional, not thresholded. A cutoff right for BTC is wrong for a thin book, and the per-asset liquidity model that would set one doesn’t exist yet (#259). Gating this on that work would block the cheap half behind the expensive half. Same principle as #247 printing the fee rate — make the number visible first, act on it second.
  • Signed, not absolute. “We paid up” and “we got filled cheaper” are opposite failures for a rule whose entry encodes a condition.
  • Never raises. Telemetry on an already-settled order must not be able to fail a cycle.
Verification, including what could not be tested

The lifespan invariant cannot be violated through the public API — since #257 there is no input that makes the engine carry a setup. So there is no red-then-green test, and writing one that passes trivially would be theatre. The test class says this in its docstring rather than implying coverage it doesn’t have.

What was done instead:

  1. Proven live. I deliberately broke the fill path and the assertion fired immediately:
    AssertionError: engine invariant violated: rule 'scripted' on BTC-USD carried a pending
    setup across 2 bars (bar index 3). Since #257 every pending fills at the next bar's open,
    so this cannot exceed 1 -- the fill path has regressed
    Then restored.
  2. It sits in the hot loop, so all 2,717 tests, the baseline golden and the whole sim suite execute it on every run. A reintroduced carry fails the suite loudly instead of silently.

The divergence log has four real tests, asserting on the structured payload (log_event attaches fields via extra) rather than caplog.text — which shows only the event name and would have passed vacuously for any values at all. I hit that exact false-pass while writing them.

2717 passed, 1 skipped; ruff check keel tests packages clean.

Refs #254, #257, #259, #260.

chore(skills): track keel-asset-scout in the repo, and record today’s measurements in it (#263)

Moves the scout skill to project scope and puts it under version control. It previously lived only in ~/.claude/skills/, outside git, with a single .bak as its entire history.

Project scope is the correct home: every path, gate and experiment the skill references is keel’s, so it means nothing in a session rooted elsewhere. There is now exactly one copy — the user-level one has been retired (parked in ~/.claude/skill-backups/, not deleted). A second copy is a second source of truth, and the two drift silently.

Tracked via git add -f, because .gitignore:34 ignores .claude/. That is the same exception the two review-fix-merge-pr skills already use — following existing precedent rather than inventing a location.

Why version control matters more here than for a typical skill: most of this document is not procedure — it is recorded reasoning about measurements that cost real compute to produce. Why stationarity is never a filter, why a small-sample profit factor is a lottery ticket, why the engine’s own invariants are unobservable from a proposer. An untracked file loses the argument along with the rule, and the next person re-derives both the hard way.

Content added today — all additive

All 13 original sections are intact, including every guardrail: Do NOT, Rationalizations, the citation-fabrication warning, and Output 2’s rejection history (the only record of why an asset was passed over, since keel deliberately has no rejection table).

AddedAnchored to
“Taker math controls the field”~2.5% round-trip, levied on the search not the edge; viable intersection vacant across 24 assets
“What has already been measured”the three experiment documents, read the restatement first
Expansion refuted as a performance fixturtle_breakout negative on all 24 — a 25th cannot fix it. Still valid for power, compliance, future rules
cost_regimea result at 5bp does not transfer to a 1.2% taker venue
sample_sizersi_meanrev 1.1631 on n=38 → 0.8396 across 82 cells at n≥100
evaluation_windowZEC compressed 92.7% of lifetime PnL into one regime atop three losing years
not_already_coveredthe skill’s own example, entry_lookback: 55, sits inside an already-swept grid
Fill-model framingentries fill at next-bar open as market orders (#258); a technique assuming a resting limit will not execute as described
4 Rationalizations rowsincl. “this backtest looks clean, the numbers are all plausible”
Two things deliberately NOT added

Both were proposed and declined, with the reasoning written into the file so the next person to propose them can see why rather than relitigating:

A check on the pending-lifespan invariant. It’s an AssertionError inside backtest() — it raises rather than logs, so a violation ends the run and produces nothing to inspect. Unobservable from a proposer that never runs a backtest.

A stationarity rejection threshold. ZEC under pullback_continuation is the only asset-rule pair in the study with no losing complete year — at 0.875 gross. Any stationarity gate passes that and rejects ZEC-turtle, which at least made money gross. Stationarity is a diagnostic, never a filter.

Verification
  • The committed copy is byte-identical to the live project-scoped file in the working checkout.
  • The file notes in its own text that being gitignored does not mean untracked here — the trap that would otherwise make a future edit look uncommitted.
  • No symlink is involved and no user-level copy remains, so there is nothing to keep in sync.

fix(packaging): ship py.typed with the four broker distributions (#264)

What

Adds an empty py.typed to keel_broker_api, keel_broker_coinbase, keel_broker_fake and keel_broker_robinhood, plus a packaging test that keeps them there.

Why

All four are checked with strict = true, but none shipped a PEP 561 marker — so every wheel through 0.7.0 carried its annotations invisibly.

Inside this repo it never showed, because mypy reads the source tree. Off it, a consumer sees:

error: Skipping analyzing "keel_broker_api": module is installed, but missing library stubs or py.typed marker

and falls back to Any for every symbol crossing the boundary. The wheel still builds, still installs, still imports — the type contract is just gone. Same silent shape as an unpinned sibling, which is why this lands as a test rather than a note.

keel_broker_api is the one that costs most: the port’s types are the contract every adapter and every consumer codes against.

The test

test_strictly_typed_packages_ship_a_py_typed_marker derives its subject list from the [tool.mypy] strict overrides instead of naming packages, so tightening a package into strict mode brings it under the rule with no edit here.

Scoped to strict modules deliberately — keel.* and keel_core.* are still ignore_errors, and a marker on unchecked code promises a guarantee nothing verifies. keel_core carries one anyway for historical reasons; the rule is a floor, not an equality, so that stays legal.

Verification
  • Confirmed the test fails when a marker is removed, not just that it passes now.
  • Checked the built wheel, not only the source tree: unzip -l keel_broker_api-0.7.0-py3-none-any.whl now lists keel_broker_api/py.typed.
  • ruff check keel tests packages clean; mypy clean (224 files); pytest 2721 passed, 1 skipped.

fix(experiments): resolve the sweep harnesses’ paths, so a recorded run can be re-run (#265)

What

Four sweep harnesses in docs/experiments/ hardcoded two absolute paths — the deployment DB under one laptop’s home, and an output dir inside a per-session temp directory that is collected when that session ends. Both now resolve, and both are overridable:

DB      = $KEEL_EXPERIMENT_DB  or ~/keel/keel.db
OUT_DIR = $KEEL_EXPERIMENT_OUT or <script dir>/_out

The default DB resolves to the same physical file it named before, so nothing changes on the machine these were written on — it just stops being the only machine where they work.

Why it matters more than it looks

A recorded experiment that cannot be re-run is the one thing a recorded experiment must not be, and the output path had already gone stale.

_out/ is gitignored but deliberately not temporary: these sweeps are resumable, reading their own append-only JSONL back to skip completed cells, so that file has to outlive the run. A per-session scratch dir is exactly what broke that.

It is also the handoff point between scripts — each *-rsi-scale run reads the JSONL its matching *-intersection run writes, as the ANCHOR supplying its oversold=20 rows. Both chains verified by name, and the two studies verified not to collide (intersection.jsonl vs intersection_257.jsonl).

Only half of this was lintable

Ruff saw two of the four files. The other two spelled the dead path as a multi-line SCRATCH constant — under the line limit, so flagged by nothing. They were found by grepping for the literal rather than trusting the linter’s list. The hardcoded DB was invisible to ruff in all four.

Lint cleanup

Also clears the 13 remaining ruff findings under docs/: 6 import blocks (autofix) and 5 long lines rewrapped without changing any output; the other 2 were the path literals above.

ruff check . now passes repo-wide — previously only the CI-scoped ruff check keel tests packages did.

Verification
  • Candles load through the resolved DB path: 44,407 bars for BTC-USD, matching the ~44k those docstrings cite. Run against a copy of the live DB, not the deployment file.
  • Defaults and both env overrides assert-checked, including that the output dir gets created.
  • ANCHOR chain confirmed intact for both study pairs.
  • _out/ confirmed gitignored and absent from git status.
  • mypy clean; pytest 2717 passed, 1 skipped.

fix(typing): scope mypy strict to the brokers, and check keel.* for real (#266)

What this is

An assessment of the codebase turned up no active bugs — the tree is healthy (2721 tests green, ruff clean). The one real gap was the mypy config itself, plus the 45 default-mode errors it was hiding on keel.*.

The config bug

strict = true is not a per-module setting. Written inside a [[tool.mypy.overrides]] block it enables strict globally, whatever module pattern the section carries. Verified directly — a section naming a module that does not exist produces the identical error count:

configerrors
broker block with strict = true131
same block targeting nonexistent_module_xyz.*131
block removed entirely45

So pyproject.toml read as “brokers strict, everything else default” but did not do that. It was invisible because keel.*, keel_core.* and tests.* all carry ignore_errors, so strict had nothing left to shout at. Ungating keel.* is exactly the action that exposes it.

The bundle is now spelled out flag by flag, scoping it to the four broker packages for real. Broker strictness is preserved — confirmed by injecting an untyped def and a bare dict into a broker module and watching no-untyped-def / type-arg still fire.

keel.* is now checked, not yet strict — that stays the next tightening step, per the existing “one package at a time, never all at once” policy. keel_core.* and tests.* remain gated.

The 45 errors

Almost all annotation debt guarded by real invariants rather than live defects. Fixed at the root instead of silenced:

  • sum() over Decimal with no start value picks the int overload and widens to Decimal | float (stats.py, levels.py).
  • Trade.pnl/exit_ts are optional because an OPEN trade has none; the aggregates run only over closed trades. Stated via _closed_pnl and an explicit filter, so a violation names the offending trade instead of raising from inside a generator.
  • TurtleBreakout tracked entry and stop as two independently-optional locals though they are set and cleared together — now one optional pair, making “stopped without an entry” unrepresentable rather than merely unreachable.
  • _effective_mode returns exactly "confirm"/"autonomous" but was typed str, so both executor.execute call sites passed an unchecked value into a Literal parameter.
  • product_id was already a de-facto part of the Rule interface (every concrete rule takes and stores it; sim reads it off the base type) — declared.
  • rules seed built rules via RULE_REGISTRY[kind](...), reaching around build_rule_from_params, the documented (kind, params) -> Rule boundary.
  • _decline’s 8 errors are a documented idiom (one-line declines so the reason cannot drift from its branch). Idiom kept; annotation corrected.
Real findings

One genuine defect — _process_rule_signals declared held: dict[str, _Held] while every other declaration, its only caller, and its own body use (asset, rule_name) tuple keys.

Two silent TUI bugs — _human_dt(None) does not raise: time.localtime(None) means now, so a missing timestamp rendered as a lapse that happened this instant. Both guarded.

A guardrail this broke, and its repair

The config change silently gutted an existing test. test_strictly_typed_packages_ship_a_py_typed_marker discovers its packages by reading strict = true, so the expanded flag list collapsed four passing cases into one skipped [NOTSET] — the PEP 561 rule stopped being enforced without anything going red. It surfaced only from diffing collected tests against baseline, not from the summary line.

Discovery now keys off the flag block, plus two new guards:

  • the strict-module list can never be empty;
  • the expanded list must still equal what the installed mypy’s --strict actually turns on, derived from mypy’s own option parser rather than hard-coded — a copy of someone else’s bundle drifts. Confirmed to fail correctly by deleting a flag.
Verification
  • mypy — clean, 224 source files
  • ruff check keel tests packages — clean
  • keel --help — imports fine (the ci.yml smoke check)
  • pytest — 2723 passed, 1 skipped; collection diffed against baseline is exactly +2, the two new tests

No runtime behaviour changes.

chore(release): 0.7.1, with the review follow-ups to #266 (#267)

Bumps all six distributions to 0.7.1 and closes the findings from reviewing #266 after it merged. Per docs/RELEASING.md, the release workflow refuses to set the version itself — that decision belongs in a reviewed PR.

Why the fixes ride along

#266 merged before its review findings were applied. Releasing 0.7.1 without them would bake a known-weak guard into a live-trading build, so they are here rather than in a follow-up.

Follow-ups to #266
  • test_broker_strict_flags_match_mypy_strict failed badly in its own headline case. Re-collapsing the broker block to strict = true — the exact regression the test exists to catch — made it die with a bare StopIteration from an exhausted generator, the least legible failure pytest renders. It now asserts, and the message points at the pyproject comment explaining why the block is expanded.
  • That test’s warn_redundant_casts exclusion was implicit. The flag is missing from the default-vs-strict diff only because it is currently mypy’s default. If that default ever flips, the flag enters expected, the test demands it in a per-module section, and mypy rejects it there as global-only — leaving the config unsatisfiable. Now excluded by name.
  • TradeOutcome used the PEP 695 type statement. get_type_hints() leaves such an alias as a TypeAliasType whose get_origin() is None, whereas the assignment form resolves through to Literal. commands.rules._declared_choices validates operator-supplied rules add --params by testing precisely get_origin(hint) is Literal. No impact today — TradeOutcome appears in no rule constructor — but it introduced the spelling that silently disables that validation, right beside the modules whose --params safety depends on the other one. Reverted to the form StopMethod/TargetMethod already use.
Tests for behaviours #266 shipped uncovered

Three new user-visible behaviours had zero coverage — all three mutations survived the entire suite. Each new test was verified to fail when its guard is removed:

  • summarize() rejecting a closed trade with no realised P&L, asserting the outcome appears in the message. The named diagnostic is the whole reason for raising: unguarded this surfaced as TypeError: unsupported operand type(s) for +: 'Decimal' and 'NoneType' from inside a generator, with no way to tell which trade caused it.
  • Both _human_dt(None) guards in the TUI. These never raised — time.localtime(None) means now — so a missing timestamp rendered the current instant as fact. Each test asserts the text says unknown and does not contain the rendered current time.

Deliberately not added: a test pinning cli.avg_hold_hours’s exit_ts filter. Both SimTrade producers set outcome and exit_ts together, so outcome != "open" implies exit_ts is not None and the filtered denominator provably cannot differ from the old one. Covering it would mean fabricating a state the code cannot reach, pinning an arbitrary choice rather than a behaviour.

Version bump

All 12 version strings across 6 pyproject.toml files, plus uv.lock. The == sibling pins move too — tests/test_packaging.py fails the build if one is left behind.

Verification
  • mypy — clean, 224 source files
  • ruff check keel tests packages — clean
  • pytest — 2726 passed, 1 skipped (2723 + the 3 new tests)
After merge

Actions → Release → Run workflow, input 0.7.1.

v0.7.0

Voir cette version sur GitHub ↗

Built from 5c434d393f33. Version binds to this hash: keel --version reports keel 0.7.0+5c434d393f33 [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.7.0-py3-none-any.whl
keel versions

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

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

Configure

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

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

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

Fixes

fix(strategy): price fills at the taker rate, and wire the dormant PBO gate into promotion (#247)

Two defects in the same family, found by docs/experiments/2026-08-11-hourly-backtest-turtle-breakout.md: a gate evaluating rules on numbers that were wrong, and a gate that was never evaluating at all. Both are cases of output that looked like a check had happened.

Baseline on 2218f46: 2696 passed, 1 skipped. This branch: 2712 passed, 1 skipped (+16 tests). No config VALUE changed; nothing under docs/experiments/ was edited.


Fix 1 — the fee defect

The simulator fills market-style at next-bar open — a marketable order crossing the spread, i.e. taker — but priced fills at the maker rate (0.006) in four places: backtest()’s default, cli._SIM_FEE_PCT, portfolio_sim.run’s default, and paper._DEFAULT_FEE_PCT.

The config was the half of the project that was right the whole time. config.yaml’s own fees: comment reads “taker_pct is the sim’s default — it fills market-style at next-bar open”, and keel_core.config.FeesConfig has carried taker_pct = 0.012 since it was written. Only the code disagreed.

Round-trip friction ran at 1.30% of notional instead of 2.50% — a 1.92× understatement of the dominant cost term. can_promote reads expectancy, win rate and realized R:R straight off these stats, so the promotion gate has been evaluating every rule at half the price of trading it.

Mechanism chosen — all three of §9.2’s options, not one

§9.2 offered (a) read from config, (b) fix the library default, (c) print the rate. They solve different problems, so all three ship.

(a) Config is the source of truth. rules backtest/rules promote and simulate thread config.fees.taker_pct into backtest()/portfolio_sim.run/edge_table; agent passes it to PaperTrader. A deployment on another volume tier or venue moves the rate by editing config, not code. Every caller checked — the two production backtest() call sites are commands/rules.py (took the default; now threaded) and sim/report.py::edge_table (already parameterised; its cli.py caller now passes config). _SIM_FEE_PCT’s five uses in simulate collapse to one sim_fee_pct local so the edge table, account pass, tier matrix and both benchmarks stay like-for-like.

(b) The library default is backtest.TAKER_FEE_PCT (0.012) for callers with no config. Deliberately the conservative choice of the two published rates: a default that overstates cost cannot manufacture an edge that isn’t there, and the one that understated it already did. A test pins it equal to FeesConfig.taker_pct, so drift is a CI failure rather than a discovery — kept as two constants so backtest.py stays free of config coupling (the same reason it imports no concrete Rule).

(c) The rate is printed — the part that matters most.

rule 1 (pullback_continuation): n_trades=0 win_rate=0.00% expectancy=0 profit_factor=0 \
  max_drawdown=0 fee_pct=1.2000% (taker, from config `fees.taker_pct`)

The source is reported too, because “from config” and “library default” answer different questions when a deployment’s config isn’t the one the operator thought they were running. The simulate report’s edge table carries the rate above it, and renders “fee rate not recorded” when a caller omits it — a gap should look like a gap. Prior numbers were unfalsifiable by their readers; that is how a 2× cost error survived in a shipped gate.

Not retroactive, in two places worth naming
  • No stored result is rewritten. Past docs/experiments/ numbers were real outputs of the code as it stood. Annotated, not restated.
  • paper.py is forward-only. Fees are journalled into orders(mode='paper') as realized cash at fill time, so this changes what the paper account records from the next fill onward and rewrites nothing stored. The paper-forward’s history is therefore spliced: pre-PR fills are maker-priced and optimistic, and track_record() pools both until they age out. Restating a journalled account’s realized cash would be falsifying its own audit trail — a worse defect than the one being fixed.

Fix 2 — wiring the dormant PBO/CSCV gate

cscv.py, deflate.py, matrix.py, g4_pbo_gate, PBOGate, and research: pbo_max/slope_floor in every config — all shipped, none connected. The gate function and its thresholds both existed; the wire between them did not.

Interface, and why this shape
  • can_promote(stats, cfg) → can_promote(stats, cfg, pbo=None, gate=None), returning a PromotionDecision. The four floors moved out to check_floors under their own name. The rename is the point: a function called can_promote that ignores overfitting reads as authoritative to every caller, which is how the gap survived review.
  • pbo=None is NOT_RUN, and NOT_RUN does not promote. A distinct state, not bool | None — “we did not check” and “we checked and it was fine” are different claims, and collapsing them is the defect.
  • PromotionDecision keeps promotable and floors_pass separate, so an operator sees which axis stopped them.
  • transition threads it and will not promote without evidence. Demotion deliberately does not require it — missing evidence must block a rule moving toward real money and must never block pulling one back.
  • pbo_gate_from_config reads the shipped thresholds. None invented, no config value moved.
The gate is satisfiable — which matters

An unsatisfiable gate just pushes everyone to --force. rules promote --pbo-session <label> runs the same ledger → build_matrix → cscv.pbo pipeline as keel trials pbo, so the number the gate applies is one an operator can reproduce by hand. Naming a session with no usable trials is a hard error, not a quiet downgrade to “not run”: asking for the check and not getting one must stay distinguishable from not asking. --force remains the WARNING-logged bypass it already was.

rule 1 (pullback_continuation): overfitting check = not_run
  - n_trades 0 < min_trades 100
  - overfitting check (G4 / PBO-CSCV) NOT RUN: no trial matrix was supplied, so the
    probability that this rule's parameters were selected by overfitting is UNKNOWN --
    which is not the same as low, and is not a pass. Supply a CSCV result (see
    `keel trials pbo`), or bypass deliberately and on the record with
    `keel rules promote --force`.
rule 1 (pullback_continuation): status -> candidate

Same defect fixed one layer out: report._render_pbo_section defaulted gate_ok to True, so a report with PBO diagnostics and no thresholds applied printed “G4: PASS”. Now renders NOT EVALUATED.

Verified end-to-end through the real pipeline: 12 pure-noise trial columns → PBO 0.83, slope −0.66 → correctly fails G4, consistent with §78.8’s random-walk calibration (−0.61). The gate discriminates; it isn’t just wired.


Existing tests changed, and why
TestChangeWhy
tests/fixtures/baseline_backtest.jsonRegenerated via the existing dev scriptThe “strategy change is intended” case that script documents. PF 1.6143 → 1.2694, expectancy 1368.48 → 692.08, max_dd 12177.59 → 14220.67 — with n_trades (13) and win_rate (0.4615) unchanged, which is the check that this was a costing change and not an accidental change to fill logic. Pinning it to the superseded rate would have preserved a golden that no longer describes the engine.
test_paper.py::FEE_PCTDecimal("0.006") → mirrors PaperTrader’s own defaultThese tests recompute expected cash by hand; a hardcoded rate silently stops testing the default — exactly how the mix-up went unnoticed.
test_promotion.py floors tests (9)Call check_floorsSame assertions, renamed callee.
test_promotion.py 2 × transition promote testsNow pass pbo=_pbo()They asserted the exact behaviour this PR removes. They document the new requirement, alongside a new test pinning that the same call without evidence does not promote.

test_backtest.py::test_fees_and_slippage_applied_on_entry_and_exit was left alone — it passes an explicit rate and pins the arithmetic, not the default, so it stays valid.

Gates
$ uv run ruff check keel tests packages scripts
All checks passed!

$ uv run mypy
Success: no issues found in 224 source files

$ uv run pytest -q
2712 passed, 1 skipped in 32.93s
Reviewer notes
  • rules promote no longer promotes without --pbo-session. Intended and central. The escape hatch is the pre-existing, audited --force.
  • rules backtest degrades to the library default (not an error) if no config is loadable — it’s read-only and useful on a bare checkout — but says which source it used. Both values are the taker rate, so the fallback can’t flatter.
  • Fee scope covers _SIM_FEE_PCT, portfolio_sim.run and paper.py because §9.2 explicitly asks for them; they were 0.006 by deliberate agreement with the wrong default.

Research & validation

docs(experiments): the promotion floor is reachable, and turtle_breakout fails it on edge (#245)

What this is

An experiment write-up recording a negative finding, plus one diagnostic_only trials-ledger row. Documentation only — no code, no config, no parameter, no rule status, no version bump.

docs/experiments/2026-08-11-hourly-backtest-turtle-breakout.md

The finding

turtle_breakout is substantially keel’s strategy: 5 of the 6 rules in keel-live.db run it. config.live-sandbox.yaml:62-94 keeps those five live despite never clearing the promotion gate, on the explicit premise that the gate is unreachable — “100 trades in ~39 years” for BTC, “~84 years” for ADA, and “waiting for the gate is not a slower path to the same place; it is no path.”

The same rule, same constructor defaults, same cached candles, re-run on ONE_HOUR instead of ONE_DAY across 19 assets:

  1. The floor is reachable. 195–274 trades hourly against 4–13 daily, over the same calendar window (BTC: 1,850 daily bars and 44,393 hourly bars are both 5.07 years). 18 of 19 assets clear min_trades=100 outright; the gate is ~1.9–2.1 years away, not 31–84, and 5 years of hourly history are already cached. The daily trade rate spans 1.19–3.18/yr across the five live assets; the hourly rate spans 47.3–54.1/yr. The rate is set by the bar clock, not the asset.
  2. And the rule fails on edge, not on sample size. At the fee the CLI actually charges: PF 0.270–1.042, 18 of 19 below 1.0, win rates 15–27%.
  3. The one apparent winner is a fee artifact. keel/commands/rules.py:100 calls backtest(rule, candles) with no fee_pct and takes the maker default 0.006, while config.paperforward.yaml:111-117 declares taker_pct: 0.012 and states in its own comment that taker is the sim’s default because it fills market-style at next-bar open. At 0.012, zero of 19 clear PF 1.0 (ZEC 1.042 → 0.736). The same 0.006 is pinned in cli.py::_SIM_FEE_PCT, portfolio_sim.run and paper.py::_DEFAULT_FEE_PCT, so promotion.can_promote has been gating on half-cost stats too. Not fixed here — raised as a follow-up issue.
  4. Independent convergence. DOGE’s shortlisting figure is daily PF 1.489 on n=12, but one trade is 63% of gross profit: 1.489 × 0.37 = 0.551, against an hourly PF of 0.558 on n=261. Two methods sharing no data beyond the price series, 0.007 apart.
What it argues
  • The standing exception’s premise is false; its conclusion may still hold on the caps and guards, and this PR deliberately does not edit that live config — §8 says why and proposes the honest replacement text for the reviewer who owns it.
  • Asset selection is not the lever. Negative on 19 of 19. Adding a twentieth cannot fix it; §73.3’s power argument for expansion is untouched, expansion as a performance fix is refuted.
  • The strongest objection is argued in §7, not buried. entry_lookback=40 is 40 hours here, atr_period=20 is no longer Turtle’s N, and TurtleBreakout hard-codes granularity = ONE_DAY so the rule is handed hourly bars believing they are days and cannot tell. A fair reading is “daily-tuned turtle does not transfer to an hourly clock”, not “trend following fails on crypto”. §7 states what survives that objection (the trade-count result, the fee defect, the DOGE decomposition) and what does not, and §9.4 proposes the resolving experiment.
Ledger

One row, hourly-turtle-granularity-2026-08-11, kind: ablation, decision: diagnostic_only (it changed nothing, so under spec §4.4 it must not count toward N), provenance: a_priori, series_missing: true. Appended through the shipped ledger.append_trial rather than hand-written, so the hash chain is produced by the same code that verifies it.

$ uv run keel trials verify
chain intact
$ uv run keel trials list | tail -3
  77  hourly-turtle-granularity-2026-08-11 turtle_breakout    a_priori  ablation         diagnostic_only [series_missing]
M=77  N_decisions=31
Gates

Baseline re-measured on this branch point before the change; identical after.

$ uv run ruff check keel tests packages scripts
All checks passed!
$ uv run mypy
Success: no issues found in 224 source files
$ uv run pytest -q
2696 passed, 1 skipped in 39.80s

docs(experiments): the hourly objection was right, and 0 of 144 tuned param sets clear break-even (#246)

What this is

A companion to docs/experiments/2026-08-11-hourly-backtest-turtle-breakout.md (#245), which showed daily-tuned turtle_breakout losing on all 19 assets at the realistic 1.2% taker fee — and which named its own strongest objection in §7 without answering it:

entry_lookback=40 means 40 hours on hourly bars, not 40 days. The parameters were never tuned for this granularity, so the finding may only show that daily params do not transfer.

That objection is the one thing standing between “the rule is negative” and “the measurement was mis-scaled”. This PR runs the test that settles it.

864 trials — 144 pre-declared parameter sets × 6 assets spanning the observed PF range (ZEC, FET, SOL, DOGE, ETH, BTC) — all completed, 0 errors, every cell priced at taker 1.2%.

Verdict

The objection was correct, and correct does not mean exculpatory. Hourly-appropriate parameters really are better — mean PF rises 0.419 → 0.634 (+51%), and the winners are the longest lookbacks exactly as wall-clock theory predicts. It is a 51% improvement on a number that has to double. Zero of 144 parameter sets average PF above 1.0 across the six assets; 8 of 864 cells clear it and all eight are the same asset in the middle of a 30× liquidity surge, at sample sizes half the promotion floor.

findingnumber
daily-tuned baseline’s rank in the grid112 of 144 — the objection was right
configs averaging PF > 1.00 of 144
cells above PF 1.08 of 864 (0.93%) vs ~43 that noise alone would give at p<0.05
where those 8 areall ZEC, n=49–67, all adx_threshold=25, all at entry 240/336
cells clearing PF 1.0 and min_trades=1000 of 864
Spearman(mean PF, mean n) over 144 configs−0.77 — edge and sample move apart

Three things I’d flag for the reader:

  • Under-shooting the noise floor is itself the finding. 8 observed against ~43 expected means the return distribution is shifted so negative that luck rarely reaches break-even.
  • ZEC’s monopoly is probably regime, not edge. Its 180-day median daily quote volume is $37.9M against a full-history median of $1,229,309 (~30×), and its close ran 36.92 → 510.43 in 2025H2. Every other asset in the sweep trades below its own historical median. The doc is explicit that this is argued, not proven — no regime split was run, and it names that split as the highest-value follow-up.
  • The bind is structural, not a tuning gap. The only productive axis buys PF by trading less. The best config clearing n≥100 is 0.584; the best overall is 0.634 at n=91.
What is now eliminated, and what to test next

Asset selection (19 assets, #245), granularity (daily→hourly, #245) and parameters (144 sets, here). What remains is the rule itself or execution cost — and cost is the larger measured lever: the same six assets at maker pricing average 0.640, which beats the best of all 144 tuned configs at taker (0.634). Changing the fee is worth more than the entire grid. §8 recommends that, with the caveat that maker pricing is a different fill model (adverse selection on breakout limits), so 0.640 is an upper bound and is still below 1.0.

Contents
  • docs/experiments/2026-08-11-hourly-param-sweep-turtle-breakout.md
  • docs/experiments/2026-08-11-hourly-param-sweep-turtle-breakout.py — the sweep script. Grid lives in its docstring, which is where it was declared before the run. Paths were adjusted on copy-in (scratchpad → --db/--out, cache opened mode=ro); noted in §2 and in the docstring.
  • One ledger row.

No code, no config, no parameter, no rule status, no version bump. The 0.6% fee defect this run prices around is deliberately still unfixed — §9 records that these numbers are stricter than the project’s own gate (backtest.py:171, portfolio_sim.py:225, paper.py:46, cli.py:1691 all still 0.006), which is a comparability hazard the doc flags rather than patches.

Ledger

One row, hourly-turtle-param-sweep-2026-08-11, kind: ablation, provenance: a_priori (the grid was pre-declared), decision: diagnostic_only, n_trials: 864 recorded honestly so nobody re-runs this grid without knowing it has been paid for.

$ keel trials verify
chain intact
$ keel trials list | tail -1
  78  hourly-turtle-param-sweep-2026-08-11 turtle_breakout  a_priori  ablation  diagnostic_only [series_missing]
M=78  N_decisions=31
Gates
$ uv run ruff check keel tests packages scripts
All checks passed!
$ uv run ruff check docs/experiments/2026-08-11-hourly-param-sweep-turtle-breakout.py
All checks passed!
$ uv run mypy
Success: no issues found in 224 source files
$ uv run pytest -q
2696 passed, 1 skipped in 32.78s

Identical to the 2bf5c6f baseline measured before any change.

docs(experiments): at zero fee turtle_breakout makes money and rsi_meanrev does not — two failure modes, not one (#248)

Third in the hourly-backtest series, and the first one whose finding is about the previous two rather than about a rule. It assumes both companions have been read and does not restate them.

Verdict: the inference the operator drew from the first two documents — that execution cost is the binding constraint system-wide, since two structurally opposite strategies both fail at 1.2% — is half right, and the wrong half is the half that would have set the roadmap. Priced at zero, turtle_breakout is profitable on 4 of 4 assets (BTC 1.090, ETH 1.458, SOL 1.533, ZEC 2.713) and rsi_meanrev is not (BTC 0.775 at n=255, FET 0.907 at n=284; ZEC 1.094 dies at a 0.093% fee). Turtle has a gross edge that cost destroys; rsi_meanrev has no gross edge for cost to destroy. Pooling them predicts that maker fills rescue both — which cutting cost to zero, an intervention strictly better than any maker rate on any venue, does not.

What is in the PR
  • docs/experiments/2026-08-12-fee-curve-and-rsi-meanrev.md
  • docs/experiments/2026-08-12-fee-curve-and-rsi-meanrev-sweep.py — the abandoned first rsi_meanrev grid, committed because it was paid for (448 trials) and because the reason it was abandoned is a finding. Its pre-registration docstring is unedited, stale numbers and all.
  • docs/experiments/2026-08-12-fee-curve-and-rsi-meanrev-diag.py — the diagnostic grid.
  • Two rows appended to docs/experiments/trials-ledger.jsonl via the shipped keel/research/ledger.py::append_trial.

Paths were adjusted when the scripts were copied in (deployment sys.path / ~/keel/keel.db / scratchpad output → --db / --out with repo-relative defaults, cache opened mode=ro); the grids, fee, asset lists, metrics, resume logic and per-trial bodies are the runs’. Recorded in both docstrings and in the document.

No code, no config, no parameter, no rule status, no version bump, no demotion. The fill model is deliberately not touched — §8 argues that changing the fee rate without changing it would be a worse costing error than the one #247 just fixed, because it would be deliberate.

The three arguments worth reviewing

1. Two failure modes, not one (§3, §7). The symptom at 1.2% converges; the disease does not. The document argues explicitly what pooling would have cost: a maker-execution programme sold as a platform-level fix, validated on turtle where it works, rolled out to rsi_meanrev where zero cost leaves BTC at 0.775. It also names the error in the other direction — pooling reads as evidence against the venue and would discard a real positive result.

2. The maker pivot is justified, narrowly (§4, §8). Break-even fee_pct measured by bracketing rather than interpolated — BTC 0.068%, ETH 0.433%, SOL 0.751%, ZEC 1.741%, a 26× spread across four assets running the same rule on the same clock. At the 0.6% maker rate SOL and ZEC clear, ETH is a near miss, BTC is unreachable. The first SOL bracket was placed from a linear interpolation and missed in the direction that flatters the maker argument; §4 records that too. And the two assets maker execution rescues are exactly the two below min_trades=100 (n=92, n=50), while the two with enough trades to promote stay unprofitable — the companion’s Spearman −0.77 bind reappearing on a different axis.

3. The document records its own wrong turns (§5, §6). The prediction that support_proximity_pct was the binding gate was wrong (10× widening buys ×1.19), and the correction — that level_min_touches was co-binding — was also wrong (×1.185, indistinguishable from the gate it replaced). oversold binds alone (×3.93). And the abandoned grid’s own n≥100 subset (48 cells) already gave the answer the purpose-built grid later produced, to within 0.016 in max PF.

Re-derivation

Every figure was recomputed from the raw JSONL rather than taken from a running summary, and several summary figures did not survive: the first grid’s median n (17, not 6), its max n (224, not 80), its trial accounting (448 rows / 432 distinct cells / 16 resume duplicates), the sample sizes behind its apparent winners (n ∈ {2, 3, 16}), and the claim that every fee-curve config sits below the 100-trade floor (BTC n=123 and ETH n=121 clear it). The first grid’s raw file was still being appended to during analysis — its producing process was still resident hours after abandonment — so all figures come from a frozen snapshot whose md5 is recorded in the document.

Ledger — two rows, not one

rsi-meanrev-grid-abandoned-2026-08-12 and rsi-meanrev-diagnostic-and-fee-curve-2026-08-12, both kind: ablation, provenance: a_priori, decision: diagnostic_only, both series_missing. Neither counts toward N. §10 argues the split: provenance is a per-row claim about a pre-registration and no single declaration covers both grids; the second grid was declared after seeing the first’s data; and the ledger’s job is to stop trials being re-spent, which a merged row would defeat by burying the dead grid inside a row headlined by the diagnostic result.

Trial budget

Reported honestly: 617 today — 448 (abandoned grid) + 108 (diagnostic grid) + 61 (fee curve, zero-fee and bracketing cells) — on top of the prior document’s 864, for 1,481 on this line of work. Both grids were pre-declared in their script docstrings, hence a_priori on both rows. Of the 448, only 432 are distinct cells and 16 are resume re-runs; all 448 are counted, because a duplicate buys no information but consumed a draw.

Limits

No walk-forward, no out-of-sample split, no CSCV/PBO on these configurations (both rows are series_missing, so they cannot enter the matrix #247 just wired into promotion), one rule family with gross edge and one without, 4–6 of 19 assets, the same cached candles and the same 2021–2026 cycle as everything else. Every fee-curve configuration is the argmax of a 144-cell search and none of them is an edge estimate; §9 states that at full strength and specifies what a maker re-test would have to look like to avoid laundering the bias.

Other changes

chore: bump to 0.7.0 for the taker-fee correction and the live PBO gate (#250)

Bumps all six workspace packages and every inter-package == pin from 0.6.1 to 0.7.0, and relocks. No other code changes.

Closes #249.

Why this release matters operationally

The ~/keel deployment is running 0.6.1, whose backtest() still defaults to fee_pct=Decimal("0.006"). The simulator fills market-style at the next bar open — taker behaviour — so every backtest run on the box today is costed at the maker rate. #247 fixed that on main six commits ago and it has never been released, which means the promotion gate is currently reading numbers that are roughly twice as favourable as the fee schedule the config declares.

Why minor, not patch

#247 is not a bugfix in the version-number sense. It changed can_promote’s return type from a bare (bool, reasons) tuple to a PromotionDecision, and it added a blocking condition that did not previously exist: a missing trial matrix (pbo=None) is now NOT_RUN, appears in reasons, and fails closed instead of passing silently. A rule that cleared promotion under 0.6.1 can be refused under 0.7.0 with identical stats.

Every backtest figure this release produces also differs from 0.6.1’s, for the fee reason above. Callers who diff results across the upgrade should expect that and not treat it as a regression.

What is in the release
PR
#247fix(strategy): price fills at the taker rate, and wire the dormant PBO gate into promotion
#248docs(experiments): at zero fee turtle_breakout makes money and rsi_meanrev does not — two failure modes, not one
#246docs(experiments): the hourly objection was right, and 0 of 144 tuned param sets clear break-even
#245docs(experiments): the promotion floor is reachable, and the rule fails it on edge
Verification
  • uv lock refreshed, so the release build is not dirty (same reason as dc99601).
  • grep -rn "0\.6\.1" --include=pyproject.toml --include=uv.lock . | grep -i keel → empty.
  • uv run pytest -q → 2712 passed, 1 skipped. tests/test_packaging.py fails the build if a pin is left behind, so a green suite is itself the pin check.
  • Diff is version lines only — no third-party constraint, requires-python, or keel/_build_info.py was touched (the release workflow stamps the latter).

After merge: Actions → Release → Run workflow with input 0.7.0, then install the four deployment wheels into ~/keel/.venv by path and run keel versions + keel migrate against both databases.

v0.6.1

Voir cette version sur GitHub ↗

Built from 9417343ecc0a. Version binds to this hash: keel --version reports keel 0.6.1+9417343ecc0a [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.6.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.

Fixes

fix(deploy): upgrade every keel distribution, and verify all of them (#243)

Found while deploying v0.6.0. The ~/keel venv, immediately before the install:

keel-broker-api      0.5.5
keel-broker-coinbase 0.5.5
keel-broker-fake     0.5.5
keel-core            0.5.5
keel-trader          0.5.7      <- only this had been upgraded, across TWO releases

The documented step installs keel_trader only. Its siblings are required with no version, so the keel-core 0.5.5 already on disk satisfied keel-core and was never touched. The engine ran new code against old libraries for two releases.

The verification step could not see it. The README calls keel --version “the check that matters”; it reports the keel-trader distribution’s version and nothing else, so it printed 0.6.0 while keel-core sat at 0.5.5. A check blind to the failure mode is worse than none, because it is trusted.

Reproduced end to end from the real wheels (0.5.5 set built from 2a58ffb with the versions rewritten, 0.6.0 set built from 2a58ffb unchanged), running exactly what the README said:

$ uv pip install --python .venv --find-links Release Release/keel_trader-0.6.0-py3-none-any.whl
 - keel-trader==0.5.5
 + keel-trader==0.6.0
$ uv pip list --python .venv | grep keel
keel-broker-api      0.5.5
keel-broker-coinbase 0.5.5
keel-broker-fake     0.5.5
keel-core            0.5.5
keel-trader          0.6.0
$ .venv/bin/keel --version
keel 0.6.0+bbbbbbbbbbbb [release]
1. The install command
V=0.6.0
gh release download "v$V" --repo CodeGateSoftware/keel --pattern '*.whl' --dir Release/
uv pip install --python .venv --find-links Release \
  Release/keel_core-$V-py3-none-any.whl \
  Release/keel_broker_api-$V-py3-none-any.whl \
  Release/keel_broker_coinbase-$V-py3-none-any.whl \
  Release/keel_trader-$V-py3-none-any.whl
.venv/bin/keel versions
.venv/bin/keel status

A wheel path is a direct requirement — that exact file is installed regardless of what is already there — so naming all four is what actually moves them. --find-links Release stays, now resolving the pinned siblings locally rather than from PyPI where they do not exist. Installing by path rather than by bare name is unchanged and still explained: keel on PyPI is an unrelated project.

Deliberately not Release/*.whl, the obvious route. uv build --all-packages builds every workspace member and gh release create dist/* publishes all of them, so the glob installs two distributions production must not have. Measured:

$ uv pip install --python .venv --find-links Release Release/*.whl
keel-broker-fake       0.6.0
keel-broker-robinhood  0.6.0
pynacl                 1.6.2
cffi                   2.1.1

The four named wheels are keel-trader’s whole dependency closure — nothing more, nothing less.

2. Pins — decided yes, with wheel-metadata evidence

Every intra-workspace dependency is now == the workspace version, in the root and in all four package pyproject.tomls.

[tool.uv.sources] workspace = true governs resolution during development and says nothing about published metadata, so I built and read the wheel rather than assuming. Before:

$ unzip -p keel_trader-0.6.0-py3-none-any.whl '*/METADATA' | grep Requires-Dist
Requires-Dist: click>=8.4.2
Requires-Dist: keel-core
Requires-Dist: keel-broker-api
Requires-Dist: keel-broker-coinbase

After:

Requires-Dist: click>=8.4.2
Requires-Dist: keel-core==0.6.0
Requires-Dist: keel-broker-api==0.6.0
Requires-Dist: keel-broker-coinbase==0.6.0

The constraint lands, and the pin alone fixes the bug for anyone who ignores the README — the old command against the new metadata, from the same 0.5.5 starting state:

$ uv pip install --python .venv --find-links Release Release/keel_trader-0.6.0-py3-none-any.whl
 - keel-broker-api==0.5.5      + keel-broker-api==0.6.0
 - keel-broker-coinbase==0.5.5 + keel-broker-coinbase==0.6.0
 - keel-core==0.5.5            + keel-core==0.6.0
 - keel-trader==0.5.5          + keel-trader==0.6.0

Why pin, given the README fix already works. The README binds only the person reading it. The pin binds pip. These are not third-party dependencies with independent release cycles — they are cut from this repo, at one version, in one uv build --all-packages, so == states a fact rather than guessing at compatibility, and there is no version skew it would wrongly forbid. The cost is that a bump must move the pins; the bump commit already edits version in all five files, so this is incremental work on a commit that exists rather than a new obligation, and tests/test_packaging.py fails the build if a pin is left behind — the failure mode of a forgotten pin is otherwise silent (the wheel still builds and still installs, it just stops forcing the upgrade). >= was rejected: it forbids an older sibling but not a mixed install, and mixed is the thing that happened. uv.lock is unchanged — workspace sources already resolved to the members.

3. A verification step that can fail

New keel versions, registered in keel/cli.py. It prints the same build-identity line, then every keel-* distribution in the running interpreter’s environment, and exits non-zero when they disagree. No config, no database, no network, so a non-zero exit is unambiguous.

Healthy — the deployment after the new install command:

$ .venv/bin/keel versions
keel 0.6.0+deb8fa7e978d [release]

keel-broker-api       0.6.0
keel-broker-coinbase  0.6.0
keel-core             0.6.0
keel-trader           0.6.0

ok: 4 keel distributions, all at 0.6.0.
exit=0

Mismatched — same venv, keel-core forced back to 0.5.5 with --no-deps (the pin has to be bypassed on purpose now):

$ .venv/bin/keel versions
keel 0.6.0+deb8fa7e978d [release]

keel-broker-api       0.6.0
keel-broker-coinbase  0.6.0
keel-core             0.5.5
keel-trader           0.6.0

error: PARTIAL INSTALL: 4 keel distributions at 2 different versions (0.5.5, 0.6.0).
`keel --version` reports keel-trader's version alone and cannot see this. Reinstall
every wheel by path (README, 'Deploying a new version').
exit=1

keel --version on that same venv now says so too, on stderr, leaving its stdout string exactly what it was (the release workflow greps it):

keel 0.6.0+deb8fa7e978d [release]
warning: PARTIAL INSTALL -- this line reports the keel-trader distribution only, and the
other keel distributions do not agree with it. Run `keel versions`.

It ships in the wheel, not in scripts/. scripts/ is operator tooling and is not packaged; a deployment is a .venv beside a Release/ directory with no checkout of this repo, so a script there could not be run without first fetching it. The check has to travel inside the artifact it is checking. The rules live in keel.version.InstallReport as a pure value, tested without installing anything; the command is rendering and an exit code. The release workflow now runs keel versions against the wheels it just built, so a release cannot publish a set that disagrees with itself.

keel-broker-fake

It should not be there and the check now says so: a release build with keel-broker-fake installed exits 1 with uv pip uninstall --python .venv keel-broker-fake. A checkout does not fail — that is exactly where the fake belongs, and a check that cried wolf on every developer’s machine would be ignored by the time it mattered. tests/test_packaging.py also asserts nothing in the workspace can pull it in as a runtime dependency. The dev group is untouched.

It is inert today (nothing calls load_broker()), but it registers a fake venue under keel.brokers, and “inert” is a property of this release rather than of the package. The README and the RELEASING asset table now say not to install it, which is also the reason the install command names wheels instead of globbing.

Gates
$ uv run ruff check keel tests packages scripts
All checks passed!

$ uv run pytest -q
2696 passed, 1 skipped in 30.51s
SKIPPED [1] packages/keel-broker-api/keel_broker_api/conformance/suite.py:254

$ uv run mypy
Success: no issues found in 224 source files

Baseline on origin/main, re-measured in this worktree, was 2669 passed / 1 skipped; +27 tests, same single expected skip.

No version bump here — that stays a separate reviewed change. docs/experiments/ untouched.

Docs, CI & tooling

docs: separate paper from live, so a figure from one is never read as the other (#242)

Why

Conflating the paper and live accounts is the single most costly confusion in this project. It produced a wrong diagnosis: a paper-run position size was checked against live-account equity, and the conclusion was wrong as a result.

Nothing in the docs made the split explicit, and nothing on the dashboard says which account a number belongs to — paper and live render in an identical layout. This makes the mistake hard to repeat.

What

README.md — new ## Paper vs. live section (placed before ## Before trading live). It leads with the thing that causes errors — two separate accounts, separate databases, separate configs, separate schedules, separate histories, so a figure from one says nothing about the other — then a comparison table of the measured values, then:

  • which one am I looking at — equity_state_mode on any dashboard, the --config/--db pair on the command line, and that --db defaults to keel.db, so a live command that omits it silently reads the paper database;
  • order gating — paper places freely against synthetic cash; live runs mode: confirm, which asks for a typed y and fails closed headless unless autonomy is armed, cross-referencing Confirm vs. autonomy;
  • cadence — “it fires hourly” and “it runs once a day” are both true. Both jobs have hourly triggers plus RunAtLoad; both runners are day-stamped; the trigger count is catch-up breadth, not cadence, because launchd does not re-run an interval that passed while the machine was off. A failed cycle leaves no stamp and is retried next hour. The two differ only in anchor: paper is local-date, live is UTC-date.
  • sizing — paper sizes off its own synthetic equity (equity_override); live falls back to caps.max_exposure_usd as a proxy. Same rule, same setup, same day, different quantities.

keel/commands/tui.py — new Which account is this? help section (a few lines, before Live balance): which field disambiguates, and that switching accounts means relaunching with the other --config/--db pair, since there is no in-app switch.

tests/commands/test_tui.py — one test in the existing section-scoped style, pinning that the help names equity_state_mode, both flags, keel-live.db, and the keel.db default.

Notes
  • Documentation only. No behaviour, default or config value change. pyproject.toml untouched, nothing under docs/experiments/ touched.
  • Branched from origin/main alongside the open #240. Both files that overlap are edited in separate, non-adjacent sections: this README section sits at the end of the file rather than next to #240’s ### How much money moves (which it cross-references instead of restating), and the TUI section sits before Live balance rather than at #240’s Glossary anchor. No new module-level test helper, so #240’s _help_section() cannot collide.
  • One deviation from the brief worth flagging: the live plist explicitly warns against describing the live path as placing nothing unattended, because the deployment runs with autonomy ON. The confirm-mode text is therefore qualified with autonomy rather than stated flat.
  • The two plists are not identical in cadence — paper has 12 local triggers (09:00–20:00), live has 24 anchored on UTC at :20. The design is the same; the section says so and names the difference.
Gates
$ uv run ruff check keel tests packages scripts
All checks passed!

$ uv run pytest -q
2665 passed, 1 skipped in 38.56s

$ uv run mypy
Success: no issues found in 221 source files

Baseline on origin/main is 2664 passed / 1 skipped; the +1 is the new test. The single skip is the expected one:

SKIPPED [1] packages/keel-broker-api/keel_broker_api/conformance/suite.py:254: adapter serves no granularity the suite could exercise

v0.6.0

Voir cette version sur GitHub ↗

Built from fbf7cc284a96. Version binds to this hash: keel --version reports keel 0.6.0+fbf7cc284a96 [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.6.0-py3-none-any.whl
keel --version

⚠️ 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.

Features

feat(brokers): add the robinhood crypto v2 adapter behind the broker port (#192)

What this adds

packages/keel-broker-robinhood/ — a first-party adapter implementing keel’s Broker port against the official Robinhood Crypto Trading API v2, registered under the keel.brokers entry-point group and passing the shipped BrokerConformanceTests against a canned, zero-network transport.

Structure mirrors keel-broker-coinbase: an injected Transport Protocol (defaulting to None so capabilities() is answerable offline), translate.py as the single place keel’s order model becomes Robinhood’s, and adapter.py holding the capability declaration.

BrokerCapabilities(
    venue="robinhood",
    supported_orders=frozenset({"market_ioc_base", "limit_gtc", "stop_limit_gtc"}),
    supports_native_preview=False,
    synthesizes_preview=True,
    supports_fee_summary=True,
    quote_currencies=frozenset({"USD"}),
    asset_classes=frozenset({"spot"}),
)

v2 exclusively, never v1. Two contract-level reasons: v1’s cancel returns text/plain “Cancel request was submitted”, which acknowledges a request and cannot satisfy cancel_order’s “return True only when the venue confirms the cancellation for THIS order id”; and v1 carries neither the per-order fee_charged get_order needs nor the fee_tier_status get_fee_summary is built from.

The three capability gaps

1. No candles. The v2 API has no OHLC, historical, or candles endpoint at all — only best_bid_ask and estimated_price. get_candles raises ValueError for every granularity, which is the port’s sanctioned way to say “I serve no bars” (_any_candles catches ValueError per granularity and skips). It must not return []: an empty list reads downstream as a statement about the market when the truth is a statement about the API. Robinhood is an execution venue here; bars come from elsewhere.

2. No quote-sized market orders — so this adapter cannot open positions. Robinhood’s market_order_config accepts only asset_quantity. keel places entries as MarketIOCByQuote, so market_ioc_quote is absent from supported_orders and raises UnsupportedOrder. Synthesising it by dividing an estimated price is deliberately not implemented — it would accept an order sized in one basis and place an order sized in another, on the live-money path, invisibly. translate.to_order_body refuses it a second time as defence in depth. What the adapter can do is exits (market_ioc_base), resting take-profit limits, and protective stop-limits.

market_ioc_base is declared even though Robinhood accepts no time_in_force on market orders. That is a naming impedance, not a capability lie: a market order is immediate by construction and there is no resting-market variant to confuse it with. The kind that would be a lie is the quote-sized one, and it is not declared.

3. No sandbox. Robinhood ships no test environment. Every test runs against a canned in-memory transport; the conformance suite is the only end-to-end signal short of real money. This is also why RobinhoodAdapter() defaults transport=None — there is no “harmless” configuration that talks to a real endpoint.

A fourth, smaller gap worth calling out at review time: get_fee_summary().fees_usd is always Decimal("0"), because v2 exposes per-order fee_charged but no account-level fees-paid total. FeeSummary’s docstring says subscription-lapse detection leans on that field, so against this venue the test is inert and detection falls back to attestation alone. Documented loudly in both the method docstring and the README; closing it means paging order history and summing fee_charged, which needs its own rate-limit design.

Not wired to the live path

Deliberately. keel/commands/_common.py still constructs CoinbaseClient directly and the broker-port migration (Phase B) has not landed. Installing this package registers robinhood for entry-point discovery and nothing more — no command, rule, or rail constructs a RobinhoodAdapter. Stated in the README too.

…truncated — full description in #192.

feat(tui): add the v activity feed — what keel has been DOING, cycle by cycle (#235)

The problem

keel has been running fine for weeks and has not traded. The dashboard makes that look like death — and it cannot help, because every one of its six panels reports state, and state is exactly what a correctly-idle deployment and a crashed one have in common: the same zeroes, the same empty position list, the same flat equity.

What is missing is the narrative, and the narrative already exists. Every cycle emits agent.cycle_start, agent.feed_polled, agent.signals_evaluated, engine.setup_detected/setup_rejected, guards.check_failed, agent.enter_evaluated into the structured JSONL engine log, all correlated by the cycle_id keel/agent.py binds via keel_core.telemetry.bind_cycle. Nothing surfaced it.

What this adds

v — a chronological activity feed, newest first, one row per engine cycle, expandable to the events inside it. A quiet cycle still gets a row: the run of quiet cycles is the answer to “is it alive”, and a feed that omitted them would reproduce the very impression it exists to fix.

  • keel/commands/activity.py — new, pure, no curses: bounded tail read, parse, group by cycle_id, summarise, render to plain text. The whole testable core.
  • keel/commands/tui.py — the v overlay, styling what those renderers already produced, exactly as i/s/p/d do for insights/admission. New key v (verified free), help section, footer row.
Real-data proof

Run against the actual deployment log, ~/keel/logs/keel.log (815 KB, read-only, untouched):

# /Users/…/keel/logs/keel.log  (815357 bytes)
# read+group+summarise: 3.7 ms   status=ok  records=329  skipped=0  cycles=77

   when                 mode    sig  blk  ent  exi  err  what happened
 ▸ 2026-08-11 09:00:09  paper     0    0    0    0    0  5 products / turtle_breakout  gate rejected: choppy_regime (PAXG-USD)
 ▸ 2026-08-10 09:00:01  paper     0    0    0    0    0  5 products / turtle_breakout  quiet -- looked, nothing to do
 ▸ 2026-08-09 09:00:04  paper     0    0    0    0    0  5 products / turtle_breakout  quiet -- looked, nothing to do
 ▸ 2026-08-08 09:00:06  paper     1    1    0    0    0  5 products / turtle_breakout  rail veto: per_asset_concentration_cap | rail veto: monthly_subscription_allowance | not placed: paper: vetoed by rails
 ▸ 2026-08-07 09:00:05  paper     0    0    0    0    0  5 products / turtle_breakout  quiet -- looked, nothing to do
 ▸ 2026-08-06 09:00:03  paper     0    0    0    0    0  5 products / turtle_breakout  quiet -- looked, nothing to do
 ▸ 2026-08-05 09:00:01  paper     0    0    0    0    0  5 products / turtle_breakout  quiet -- looked, nothing to do
 ▸ 2026-08-05 08:12:58  --        0    0    0    0    2  uncorrelated events (no cycle_id)  error: cb_client.accounts_fetch_failed | error: executor.quote_fetch_failed

That is the answer to “why hasn’t it traded”, in eight lines: it looks every day, it found exactly one setup in a week, two rails vetoed it, and the account reads have been failing separately the whole time.

Enter on the 2026-08-08 row:

…truncated — full description in #235.

feat(tui): scope the activity feed to the current day by default (#237)

Follow-up to #235, which merged before this landed. #235 added the v activity feed; this scopes it.

The instruction

“make sure the activity displayed in the TUI is for the current day, don’t display historical or old activities”

The feed as merged showed every cycle in the bounded read window — 77 rows on the real deployment, going back three weeks. It now shows today.

What “today” means

The local calendar day, midnight to now, derived through datetime.date in the same local clock _stamp renders the timestamps in. Not a rolling 24 hours: on a deployment that runs at 09:00, a rolling window would put yesterday’s cycle on screen every morning and drop it every afternoon, so the same day’s feed would change shape depending on when it was opened.

Going through date → naive midnight → .timestamp() uses the offset in force on that day, so a 23- or 25-hour DST day still starts where the civil day starts (a fixed now - 86400 would drift an hour twice a year). In the few zones whose transition happens at midnight, so 00:00 does not exist, the conversion resolves an hour early — deliberately the safe direction, since a boundary that errs early can only ever include a cycle that belongs to today, never exclude one.

The boundary is inclusive, so a cycle at exactly local midnight belongs to the day beginning then. There is deliberately no upper bound at now: in live use now is the current instant, so the only record an upper bound could exclude is one stamped in the future by a writer whose clock is ahead — and in this module of all modules, showing the anomalous row beats quietly shrinking the panel.

Where it lives

keel/commands/activity.py, the pure layer — apply_scope(feed, scope, now_ts=...), a separate pass over feed_from_lines’s result rather than a parameter threaded through it. That keeps “parse the window” and “decide which days to show” independently testable, and keeps feed_from_lines the unscoped function every existing caller already relied on. now_ts is injected (defaulting to time.time() at exactly one seam), so no test in this change depends on the day it runs.

run_live passes the same now_fn the rest of the dashboard clocks itself with, so the day boundary can never disagree with the timestamps on screen.

The toggle: t

t cycles today → 7 days → all, shown in the header. Verified free — q Q h ? i r a f s p d v are the dashboard’s keys, k/j/Enter/Space the in-overlay ones, and nothing bound t.

The scope resets to today on every open and every close. A widened view answers one question once; it never becomes tomorrow’s default.

The empty state — the important part

The deployment runs once a day, at 09:00. “Today” therefore holds at most one row, and none at all before 09:00. A blank panel there would be strictly worse than the state-only dashboard this feature exists to fix, because a blank panel and a dead agent look identical.

So an empty day is never blank. Rendered from the real ~/keel/logs/keel.log exactly as the overlay would have shown it at 07:00 this morning — the log filtered to the records that existed at that moment:

keel tui -- activity
scope: today (2026-08-11)  ·  0 cycles  ·  76 older hidden  ·  press t to widen

keel has not run yet today.
Last cycle: 2026-08-10 09:00:01 -- yesterday, 21h 59m ago.
Next cycle due today around 09:00 local -- in 2h 00m.

That one line is all the history this panel shows: it is scoped to today.
Press t to widen the scope: today -> 7 days -> all history in the window.

source: /Users/…/keel/logs/keel.log  (315 records in window)
scope today (2026-08-11): 76 older cycle(s) in the window are hidden -- press t to widen

Three lines answer “is keel alive” before any question of what it did. The next-run estimate is inferred from the last cycle’s own time of day, taken from the log rather than from a schedule setting this module would otherwise have to be taught about and could then disagree with. When that time has already passed, it reads differently, because it is different news:

keel has not run yet today.
Its usual start time today (09:00 local) passed 5h 00m ago.
If no row appears here shortly, check that the agent's schedule is still running.

…truncated — full description in #237.

Fixes

fix(brokers): close the review findings on the robinhood adapter (#194)

Follow-ups to #192, which merged before these review fixes landed. Every finding below was raised against that PR; the adapter shipped to main with all four blockers live, so this is the catch-up. Diff is fix-only — the package itself is already on main via the squash.

Not merging — that is the user’s call.

Blockers
B1 — str(Decimal) emitted scientific notation into the order body

str(Decimal("0.00000001")) == "1E-8", and BTC’s asset_increment is exactly 0.00000001 per this repo’s own tests/fixtures/rh_trading_pairs.json. One satoshi is therefore the smallest order this venue accepts, not an edge case — it is the size a dust-sized exit produces. Robinhood’s asset_quantity / limit_price / stop_price have no exponent form, so that body is malformed.

The failure mode is the quiet kind: a rejected exit leaves a position open while the engine records it closed, and a rejected stop-limit leaves a position unprotected while local state says there is a stop. Every money and size field now renders through translate._render (format(d, "f")), which is positional at every magnitude and neither rounds nor truncates.

The test that should have caught this asserted the property over 0.123456789 and 64000.10 — two values that cannot trigger the exponent form. It is now parametrized over values that do (0.00000001, 0.000000012345, 1E+2 → 100), plus a structural assertion that no rendered field ever contains an e. Confirmed failing against the old implementation before fixing.

B2 — place_order reported success for orders the venue rejected

The old code checked only that an id came back, on the stated reasoning that “a placement that comes back at all came back as an order.” That is false here: Robinhood answers a rejected order on the happy HTTP path — 200, with a real order object whose state reads failed.

A StopLimitGTC answered {"id": "...", "state": "failed"} was recorded as a protective stop that does not exist at the venue. failed and canceled now return PlaceResult(success=False, ...) with the state named in reason, and broker_order_id=None matching CoinbaseAdapter’s failure path.

An unrecognised state still reports success, deliberately — the asymmetry with get_order is argued in the docstring: reporting failure for an order that is actually live invites the caller to place it again, and a duplicate live order has no recovery, whereas success hands back the id and lets reconciliation poll (where an unknown state maps to PENDING and stays under observation).

B3 — estimated_price namespace: the reviewer was wrong, the code was right

Resolved from the primary source, not by guessing: https://docs.robinhood.com/crypto/trading/. The v2 API genuinely splits these two neighbouring reads across namespaces —

get/api/v2/crypto/trading/estimated_price/
get/api/v2/crypto/marketdata/best_bid_ask/

— which is exactly what transport.py already did. The instinct to “fix” it is understandable because v1 is the consistent one (/api/v1/crypto/marketdata/estimated_price/). Required query params confirmed as symbol, side (bid/ask/both), quantity, all three required.

No behaviour change. The path is now pinned by a test and anchored to the doc URL with the verbatim quoted paths in a comment, so this cannot be re-litigated.

B4 — get_order omitted account_number; cancel_order correctly does not

get_order now sends the same account_number query param create_order sends, per Robinhood’s own v2 sample client. Omitting it risks a 404 — and a 404 here is quiet corruption, not a loud failure: _request turns it into None, and adapter.get_order turns that into a terminal FAILED with zeroed money for an order still resting at the venue.

Partially rejecting the finding: the review asked to make all three consistent. Robinhood documents post/api/v2/crypto/trading/orders/{id}/cancel/ with a path parameter only and no query-parameter section, and their own v2 sample omits it there while passing it for place/fetch. Adding an undocumented param would be a guess, and every query byte is signed — so a guess the venue rejects is a 401 on the cancel path. Left as-is, with the reasoning written down.

Should-fix

…truncated — full description in #194.

fix(rails): bracket a held tranche whose bracket was never placed, and page a human when it cannot (#205)

Closes #195. Found by the QuantCrawler teardown (#193, §1.1) and verified against source.

The hole

place_bracket runs after the entry has already filled, so a refusal leaves a real position with no stop at the exchange. keel/agent.py recorded that as unremarkable — “Neither is an error here — the tranche is real either way” — and nothing ever revisited it.

A rails veto cannot actually cause this. Rails 3/4/5/6/8/11/13/14/16/17 are is_buy-gated and never run on a protective SELL (rail 13: “SELL is exempt — it produces quote currency, it doesn’t consume it”), and at initial placement the bracket’s notional (qty × stop) is strictly smaller than the entry that just cleared the same caps.

But if not result.placed also catches a broker rejection — min-size, precision, a venue error — which no rail can prevent. And nothing healed it:

  • reconcile_open_orders iterates status="pending". A never-placed bracket writes no order row at all (the insert is after the guard gate); a rejected one writes rejected. _rebracket_or_escalate saw neither — it only heals a bracket that was accepted and later died.
  • keel-live-run.sh gates on a UTC day-stamp (“the trigger count is catch-up BREADTH, not cadence”), so the window is ~24h, not the hour the 24 launchd triggers suggest.
  • keel kill halts new orders and closes nothing. There is no flatten.
  • The only signal was WARNING executor.bracket_not_placed.
The fix

The retry needs levels to place from, and reconcile deliberately refuses to invent them (“would silently re-risk the position on a level no rule produced”). So:

  1. place_bracket records {stop, target, qty} under unbracketed:<product> on failure, and clears it on success. Deliberately not open_stop:/open_target: — those mean “resting at the exchange”, and rail 9 reads open_stop as its no-widening reference. Writing them for an order that does not exist would tell rail 9 a stop is protecting a position when none is.
  2. reconcile_unbracketed_positions sweeps the positions ledger each cycle, re-places from the recorded levels, re-points the tranche, and escalates CRITICAL if it still cannot. Driven from the ledger because that is the only place that knows a tranche is held.
  3. A tranche with no record is skipped silently. That is DCA’s correct resting state — it carries no stop by design — and escalating it would fire a CRITICAL per DCA tranche per cycle.
  4. CRITICAL escalations now leave the machine. WebhookAlertHandler POSTs to KEEL_ALERT_WEBHOOK as the same JSON object the log file gets. Off by default; no URL means no handler and no network call. Level is on the handler, so verbosity can’t turn it into a firehose. Never raises into the caller and never logs its own failure through the keel logger (that would recurse).
Explicitly rejected
  • Exempting protective sells from the notional/exposure rails. They already are, so it buys nothing — and rail 2 is the only per-order magnitude check that runs on the sell side. Removing it would leave an oversized SELL from a qty bug with nothing between it and the exchange.
  • Auto-flatten on reject. A market sell fired into the same book that just rejected the order, at the moment the broker API is degraded.
Tests

19 new, TDD — all 10 core ones written failing first. Notably test_run_once_brackets_a_tranche_whose_bracket_was_never_placed, verified to fail when the sweep is unwired: every reconcile test calls the pass directly and would pass in full while run_once never invoked it, leaving the exact hole open.

Covered: never-placed and rejected brackets both swept; DCA left alone; sold-out product not re-bracketed; already-protected tranche untouched; failed retry keeps its record for the next cycle; escalation fires; alert payload, CRITICAL-only filtering, unreachable endpoint, double-install, and env-over-.env resolution.

ruff check keel tests packages clean, mypy clean on the strict keel-core additions, 2226 passed / 1 skipped.

Note for the reviewer

…truncated — full description in #205.

fix(rails): let rail 9 see a protective bracket’s own stop (#212)

Closes #206. Surfaced while fixing #195.

The gap

place_bracket carries the bracket’s trigger price in entry and leaves stop=None:

intent = OrderIntent(
    side=Side.SELL,
    entry=stop,   # the trigger price
    stop=None,    # -> rail 9 has nothing to check
    ...
)

That shape is correct — the order is the stop, not an entry protected by one somewhere else. But rail 9 (no stop-loss widening) is guarded by intent.stop is not None, so it skipped every bracket keel has ever placed.

The ratchet-only invariant was still enforced, but only inside _roll_stop — which has no production caller (tests/execution/test_executor.py:1415 is an explicit tripwire asserting that). So on the live path, the only thing checking that a bracket’s stop had not moved against the position was code that never runs.

The fix

OrderIntent.protective_stop, set by place_bracket and _roll_stop, read by rail 9 when stop is absent:

proposed_stop = intent.stop if intent.stop is not None else intent.protective_stop

Why a separate field and not just stop — this is the part worth reviewing. Rail 7 (min-move/anti-scalping) measures abs(entry - stop) / entry, and a bracket’s entry and stop are the same price by construction. Populating stop would compute a 0% move and veto every protective order keel places on the anti-scalping floor. Two rails asking two different questions need two fields. test_a_protective_bracket_is_not_vetoed_by_the_min_move_floor pins this.

Rail 7 remains inert on brackets by design — there is no entry-to-stop distance to measure. The module docstring now says so rather than implying coverage that was never there, which was the original complaint in #206.

The comparison stays strictly <, never <=. Re-placing at the same level is exactly how _rebracket_or_escalate and reconcile_unbracketed_positions recover a dead or rejected bracket — an off-by-one to <= would veto every recovery and strand the position naked, reintroducing the failure #195 just closed. test_rail9_allows_re_placing_a_bracket_at_the_SAME_stop guards it.

Tests

5 new (4 written failing first; the fifth passes already as a regression guard that protective_stop does not shadow the entry path). Covers: a widening bracket now vetoed, a ratcheting one allowed, re-placement at the same stop allowed, min-move not triggered on a bracket, and entries still using their own stop.

ruff check keel tests packages clean, mypy clean on both changed modules, 2293 passed / 1 skipped.

Scope note

No behaviour change on any path exercised today: the first bracket after an entry sees open_stop unset (it is written only after a successful placement), and both recovery paths re-place at the recorded level, which passes. This closes the gap ahead of stop rolls ever being wired up — at which point rail 9 becomes the un-overridable backstop behind _roll_stop’s own local check.

fix(brokers): read robinhood’s real estimated_price shape, correct every fixture (#218)

Findings from the first live run against a real Robinhood credential (#216), so these are statements about the venue rather than readings of the documentation. Ten requests, zero 401s: signing, headers, the timestamp window, the base URL, pagination and every endpoint path are confirmed correct and none of them are touched here.

Closes #217. Follows #216. Removes an assumed basis for part of #198.

F1 (blocker) — _estimated_price read a field the venue does not send

adapter.py read _field(rows[0], "price", "0"). The observed row is:

{'symbol', 'side', 'quantity', 'timestamp', 'fee_ratio', 'est_fee', 'ask', 'est_total_cost'}

There is no price. Every market preview against the live venue came back est_quote_size = 0.000 with errors populated — confirm mode was unusable on this venue.

  • The unit price is now read from the column named after the side that was asked for (to_price_side: buy -> ask, sell -> bid). There is deliberately no fallback to the other side’s column: pricing a sell off an ask overstates the proceeds of an exit, the exact optimistic direction to_price_side exists to prevent. A row not carrying the requested side is treated as unpriced.
  • preview_order now reads the venue’s own est_fee rather than multiplying the notional by the account’s fee_tier_status.fee_ratio. detail["fee_basis"] says which was used, and GET /accounts/ is no longer fetched at all on a healthy market preview — one request instead of two, on a path the executor uses while unwinding.
  • est_total_cost is reconciled, not assumed. See below.
  • The #194 S1 behaviour is preserved and now has more paths feeding it: a missing or unusable value still yields None -> populated Preview.errors, never a silent zero.
  • synthetic=True / supports_native_preview=False are unchanged, and the docstrings now argue for it rather than stating it: /estimated_price/ prices a quantity. It does not validate the order, check buying power, check the account’s size bounds, or reserve anything — an order it prices happily can be rejected the instant it is placed. That gap is what Preview.synthetic exists to carry, and reading more of the venue’s numbers does not close it.

Confirmed live on the branch. SELL 0.001 BTC -> quote=64.95451 fee=0.617067845, BUY 0.001 BTC -> quote=64.947570 fee=0.617001915, both synthetic=True, errors=(), two GETs and no /accounts/ call. Side mapping verified: side=ask answers with an ask column, side=bid with a bid column.

On est_total_cost: what interpretation was chosen, and why it is not a guess

I could not run live, so rather than pick a reading and hope, the relation is determined from the venue’s own numbers on every response. The row states price, quantity, est_fee and est_total_cost — one equation, one unknown — and exactly one of three readings fits any self-consistent response:

readingrelationfee-exclusive notional
est_total_costtotal == notionaltotal
est_total_cost_less_est_feetotal == notional + feetotal - fee
est_total_cost_plus_est_feetotal == notional - feetotal + fee

The third is not padding: a buy’s “total cost” plausibly adds the fee while a sell’s plausibly nets it out of the proceeds, and only the ask side was observed live.

Pushing back on one instruction in the issue: “use est_total_cost for est_quote_size” cannot be right unconditionally. Preview carries est_quote_size and est_fee as separate fields, and the limit path fills est_quote_size with base_size * limit_price — a fee-exclusive notional. Assigning a fee-inclusive total straight into it would double-count the fee at the confirm gate, once inside the quote size and once in est_fee. So the venue’s number is what is used, adjusted by the venue’s own fee according to whichever relation its numbers satisfy. detail["cost_basis"] reports which one.

A total fitting none of the three is priced from est_total_cost exactly as sent and reported through Preview.errors. That middle course is deliberate: refusing to price would degrade an exit preview over a number that is probably right, and pricing it silently would put a cost in front of a human with an unverified relationship to the order.

…truncated — full description in #218.

fix(cli): render Preview.synthetic at the confirm gate; an estimate must not look like a quote (#221)

Closes #199. Refs #198.

keel/cli.py:_interactive_confirm took a raw dict and knew nothing about the port’s Preview, so it had nowhere to render synthetic. Preview’s docstring makes that a requirement of anything rendering one:

synthetic=True means these numbers are an estimate the adapter computed, not a quote the broker returned. Anything rendering a Preview must surface that distinction: approving an estimate must never look identical to approving a broker’s own quote.

Latent today (Coinbase has a native preview endpoint, so nothing synthetic reaches a human), real for the first synthesizing venue. Worse there: an unpriceable synthesized preview comes back as zeroes, which on an undecorated key/value screen reads as a harmless “$0.00 order” rather than “keel has no idea what this costs”.

What a human sees now

Native / broker-quoted — unchanged question, new provenance rule:

Rails PASSED. Order preview:
  ========================================================================
  BROKER QUOTE -- the venue priced this order itself.
  ========================================================================
    product_id: BTC-USD
    side: BUY
    est_base_size: 0.00004821
    est_quote_size: 5.00
    est_fee: 0.03
Place this order? [y/N]:

Synthetic, priced — same numbers, unmistakably not the same screen. Still an ordinary y/N: the warning is the banner, not ceremony on every exit.

Rails PASSED. Order preview:
  !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
  !! SYNTHETIC ESTIMATE -- NOT A BROKER QUOTE.
  !! keel's adapter computed these figures from a price lookup. The
  !! venue has NOT priced, validated or reserved anything, and is bound
  !! by none of the numbers below. The fill can differ.
  !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
    product_id: BTC-USD
    side: BUY
    est_base_size: 0.00004821
    est_quote_size: 5.00
    est_fee: 0.03
    estimated_price: 103700.00
Place this order? [y/N]:

Synthetic, unpriced, carrying errors — the case the issue is really about:

Rails PASSED. Order preview:
  !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
  !! SYNTHETIC ESTIMATE -- NOT A BROKER QUOTE.
  !! keel's adapter computed these figures from a price lookup. The
  !! venue has NOT priced, validated or reserved anything, and is bound
  !! by none of the numbers below. The fill can differ.
  !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
    product_id: DOGE-USD
    side: SELL
    est_base_size: 0
    est_quote_size: 0
    est_fee: 0
  !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
  !! UNPRICED -- this preview carries no usable size.
  !! This is NOT a zero-cost order -- it is an order whose cost could
  !! not be determined. Approving it sends an order to the venue with
  !! no idea what it will spend.
  !! PREVIEW ERRORS (1) -- reported against this order:
  !!   - no estimated_price for DOGE-USD
  !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
This preview is NOT a reliable quote. To place it anyway you must type the phrase "place anyway" -- anything else declines.
Type "place anyway" to place, or press Enter to decline:
The friction, and why it is not a block

An unpriced / error-carrying / unparseable preview upgrades the question from [y/N] to a typed place anyway. A y at a [y/N] prompt is muscle memory after the tenth order of the day, and the point of the banners is that this screen is not like the last ten.

It is deliberately not a refusal. An unpriced preview is exactly what a human sees when a venue’s pricing endpoint is down and they are trying to close a position; walling off the exit path would trap a position behind a broken preview endpoint — a worse money outcome than a warned-and-approved order. Harder to do, never impossible. Ctrl-C / EOF at the prompt declines rather than raising out of the gate.

The live Coinbase path is untouched in substance

…truncated — full description in #221.

fix(brokers): sum robinhood’s per-order fee_charged into a real fees_usd (#222)

RobinhoodAdapter.get_fee_summary() returned fees_usd=Decimal("0") unconditionally. FeeSummary’s docstring names subscription-lapse detection as its consumer, and the contradiction it looks for is a fee charged while the user claims a fee-free allowance. Pinned at zero, that check did not error against this venue — it silently passed, for every account, every time. A rail that always passes is worse than an absent one, because it reads as coverage.

fees_usd is now summed from GET /api/v2/crypto/trading/orders/, filtered to the same trailing 30 days thirty_day_volume covers.

Window: the filter exists, and which one matters

I fetched https://docs.robinhood.com/crypto/trading/ rather than guessing. The page is a Next.js SPA with an empty __NEXT_DATA__; the real source is a complete OpenAPI 3.0.1 spec embedded in a JS chunk, which I extracted and read directly. The list endpoint documents account_number (required), cursor, created_at_start, created_at_end, updated_at_start, updated_at_end, symbol, side, type, state. Server-side time filters exist, so no client-side sweep-and-discard is needed.

updated_at_start, not created_at_start. A fee is charged when an execution happens, and an execution necessarily bumps updated_at — so that result set is a superset of the orders carrying an in-window fee and can never omit one. created_at_start has no such property: a StopLimitGTC resting forty days and filling this morning was created outside the window and charged its fee inside it. keel rests GTC brackets by design, so that is the normal case, not a corner. Under-reporting is the false negative this issue is about, so between two imperfect filters the correct one is the one that cannot under-report.

Decisions that could each have reintroduced the bug
  • No state filter; every state counted. A partially-filled-then-cancelled order ends canceled having been charged a real fee, so filtering to filled under-reports. No filter is needed anyway — fee_charged is documented as the fee charged based on executed fills, so the field is already its own state filter, reading zero on an order that never traded.
  • estimated_fee_remaining is never read. It is an estimate of a fee not yet charged; fees_usd is consumed as an observation.
  • An incomplete sweep raises rather than returning a partial sum. FeeSummary has no field to mark a total partial (fees_usd is a bare Decimal), so a truncated sum is indistinguishable from a complete one — the same always-passing false negative in a new costume. The transport’s existing _MAX_PAGES already raises, and get_fee_summary does not catch it. This inverts the _account/cancel_order rule (“a raise on the way out of a position can trap it”) and safely: get_fee_summary is a reconciliation read, never a step in an unwind.
Cost

1 + N requests, N being history pages in the window: one GET /accounts/ plus the page walk, capped at 20. Worst case 21 against a 100 req/min limit with no backoff; realistically 2. The server-side window filter is what stops it growing with the account’s total age forever. No bound below _MAX_PAGES was added — a tighter cap would truncate silently, and truncation is the failure this issue is about.

Two residual inaccuracies, documented not papered over
  • An order straddling the window edge contributes its whole fee. fee_charged is order-level and v2’s executions[] rows carry only effective_price/quantity/timestamp — no per-execution fee — so a fee cannot be split at the boundary even in principle. This over-counts, never under-counts: an over-count points lapse detection at a fee genuinely charged, just slightly earlier than claimed; an under-count hides one.
  • The window is not provably identical to the venue’s. thirty_day_volume’s boundary is undocumented; ours is cut from the local clock. They match in length and intent, not to the second. Comparable as magnitudes; do not divide one by the other for an exact effective rate.
Unverifiable without placing a real order

…truncated — full description in #222.

fix(analysis): scale is_round_number to the price’s own magnitude (#227)

is_round_number had no sense of scale. Fixing it, with the before/after #225 requires.

The defect
def is_round_number(price: Decimal, step: Decimal = Decimal("0.005")) -> bool:
    remainder = price % step
    distance = min(remainder, step - remainder)
    return distance <= step * Decimal("0.1")

step is an absolute half-cent. Coinbase quotes BTC/ETH/PAXG to two decimals and 0.01 = 2 × 0.005, so every quotable price is an exact multiple of step, remainder is exactly zero, and the function returns True unconditionally. The distance <= step * 0.1 test never got a chance to be false.

As weight 1 of DEFAULT_WEIGHTS’ 14, three of the five live allowlist assets carried an unconditional +1 on every CTS score. A constant is worse than a redundant factor — a redundant factor at least varies — and because it applied to three assets and not the other two, the CTS total was not comparable across the allowlist.

What “round” now means, and why

A round handle is a price with few significant figures — 65,000, 3,400, 0.38. That is a property of the price relative to its own magnitude, which is exactly what an absolute constant cannot express.

spacing  = 10 ** (floor(log10(price)) - 1)     # the two-significant-figure grid
present  ⟺ dist_to_nearest_multiple <= spacing * tolerance     # tolerance default 0.02

Computed from Decimal.adjusted() — exact integer exponent arithmetic, no float log10, which would misplace the grid a few ulps under a power of ten.

Three decisions, each argued in the write-up:

  • Two significant figures, not three. Two is what the words mean (65,100 and 0.381 are not handles). It is also what survives measurement: a three-figure grid pushes the spacing onto ADA/XLM’s quote increment, and tick quantization alone then drives the base rate — ADA reaches 0.2996 against BTC’s 0.1861 at the same tolerance, a 1.61× spread manufactured by quote precision. Same class of artifact this fix removes.
  • tolerance is a fraction of the handle SPACING, not of price. Both scale with the instrument, so both fix the reported bug; they differ in what they hold constant. A fraction of price makes the presence rate depend on where in the decade the price sits — the spacing is 10% of price just above a power of ten and 1% just below, so the same rule would fire ten times as often on BTC at 99,000 as at 10,500, and the factor would silently change meaning as an asset trended through a decade. BTC’s daily history spans 15,760–124,720, two decade crossings. A fraction of spacing makes P(present) identically 2 × tolerance regardless of price, decade position and quote precision — #225’s acceptance criterion restated as an invariant.
  • No quote_increment, deliberately. assemble_cts_context is a pure function of (setup, candles) and #224’s offline replay — the instrument measuring this very change — depends on that. Threading venue state into an analysis.* primitive to fix a factor that doesn’t need it would make the scoring path unreplayable. Tick size is a venue’s quoting rule; a psychological handle is a property of the number.
Before / after — P(present), same 6,827 daily bars

Unconditional sample, ONE_DAY, expanding window (which reproduces the live path exactly). #224’s harness, keel/research/cts_factors.py, reused unchanged — no second instrument was written.

assetNbeforeafterΔ
BTC-USD1,6481.00000.0358−0.9642
ETH-USD1,6481.00000.0334−0.9666
PAXG-USD2591.00000.0463−0.9537
ADA-USD1,6360.21700.0465−0.1705
XLM-USD1,6360.19010.0440−0.1461
pooled6,8270.61830.0401−0.5781

Cross-asset spread (max/min): 5.26× → 1.39×. That number is the acceptance test. The residual 1.39× is the sampling spread of a ~4% Bernoulli rate over 259–1,648 bars; PAXG (N=259) is the widest cell.

The repaired factor now sits inside the panel rather than dominating it — rarer than candlestick_pattern (0.2026), commoner than rsi_extreme (0.0230).

The before-arm is verified, not asserted

…truncated — full description in #227.

fix(brokers): map Robinhood’s partially_filled state to OPEN (#229)

What

STATE_TO_PORT_STATUS in packages/keel-broker-robinhood/keel_broker_robinhood/translate.py only covered Robinhood’s query-filter state enum (open, canceled, filled, failed, pending). The order-response enum instead carries partially_filled and omits pending. A partially_filled order therefore fell through to_port_status’s default and read as PENDING.

partially_filled now maps to OPEN: a partially-filled GTC limit is still resting and working at the venue, so OPEN is the honest status; PENDING reads as not-yet-working. OPEN is already in the port’s accepted status vocabulary (see the conformance suite’s status assertion).

The map now covers the union of both enums (pending was kept, not removed) since either shape could plausibly arrive in a response.

Unverified spelling

Per #198, no Robinhood order object has ever been observed live, and Robinhood’s own docs disagree about which state enum applies to the order-response object. partially_filled’s exact spelling is therefore doc-sourced, not confirmed. The added comment says so explicitly and names which of the two documented enums it came from, rather than presenting it as verified.

to_port_status’s fallthrough-to-PENDING default is unchanged — its docstring’s reasoning (an unrecognised state means the adapter doesn’t know the outcome; FAILED would falsely declare a terminal result) still holds and applies to any future unmapped state.

Latent, not a behaviour fix

Confirmed by reading keel/execution/reconcile.py: it branches only on _FILLED = "FILLED" (line 36) and _DEAD = frozenset({"CANCELLED", "CANCELED", "EXPIRED", "FAILED"}) (line 37). The status != _FILLED check at line 102 treats every non-terminal status identically — OPEN and PENDING take the exact same “still resting, keep polling” path today. This PR corrects a factual claim about the venue’s vocabulary; it does not change behaviour until some future consumer distinguishes OPEN from PENDING.

Closes #226 Refs #198

Test plan

TDD: added test_to_port_status_maps_partially_filled_to_open_not_pending, confirmed red before the fix (to_port_status("partially_filled") returned "PENDING"), green after. Existing test_to_port_status_defaults_an_unknown_state_to_pending_not_failed and test_to_port_status_defaults_none_to_pending pin that the fallthrough default is unchanged and unnarrowed.

  • uv run ruff check keel tests packages scripts — all checks passed
  • uv run pytest -q — 2445 passed, 1 skipped (baseline 2444 passed / 1 skipped + 1 new test; the one skip is the conformance candles probe, suite.py:254, as expected)
  • uv run mypy — Success: no issues found in 219 source files

fix(tooling): summarise the whole list in the probe, and restore the field it hid (#232)

Closes #230.

D1 — shape_of only ever inspected results[0]

It reduced a list to [shape_of(value[0]), "... N items"], so every probe validated one element and reported a match for the whole collection. Live, trading_pairs returns 89 pairs in two distinct key-sets: 63 carry min_order_amount (BTC-USD 0.1 and ETH-USD among them), 26 do not — and results[0] is BILL-USD, one of the 26. The probe reported 5/5 and then 6/6 matched while blind to a field on 71% of pairs, including every asset keel trades.

shape_of now merges every element: the union of the keys, a key carried by only some of them marked min_order_amount (63/89), and a key the venue types inconsistently rendered Decimal|str (77 str, 12 Decimal) instead of silently taking the first. One summary row and a count come back however long the list is — an 89-pair response never prints 89 shapes.

How a partially present key compares against a fixture

A fixture is a single representative object and cannot express “63/89”, so the convention is that the fixture carries the union of what a row can hold, and the count is reported as information rather than as a mismatch. rh_trading_pairs.json’s one row is BTC-USD, and BTC-USD is sent the minimum, so:

  • fixture omits min_order_amount → NEW AT VENUE results[].min_order_amount — the #218 regression, now catchable;
  • fixture carries it → clean match, plus a printed note.

Mechanically: every note lives in a trailing (...) suffix on the shape token, and compare_shapes compares tokens with that suffix stripped. It rides on the key, so a partially present key whose value is an object is annotated the same way as one whose value is a leaf, and it survives --json verbatim. Treating partial presence as a difference instead would fail the trading_pairs probe on every run against a venue behaving exactly as measured — the same cry-wolf failure #217 F5 taught this script to avoid.

A mixed type is deliberately not bare-equal to either of its halves, so it still reports TYPE DIFFERS with both tallies in the message. At a venue already caught quoting the same kind of value two ways in one object (#217 F6), that is a finding, not a formatting detail.

The report, on the measured 63/26 split
=== trading_pairs ===
  shape matches rh_trading_pairs.json
  note: results[].min_order_amount  present on 63/89 elements

and in --json:

"results": [
  {
    "asset_code": "str",
    "asset_increment": "str",
    "is_api_tradable": "bool",
    "max_order_size": "str",
    "min_order_amount (63/89)": "str",
    "quote_code": "str",
    "quote_increment": "str",
    "status": "str",
    "symbol": "str"
  },
  "... 89 items"
]

That exact run — 89 rows, BILL-USD first, 63 carrying the key — is asserted end to end in test_the_real_63_of_89_split_matches_the_fixture_and_is_reported, including exit code 0 and the note line.

D2 — the regression

min_order_amount is restored to tests/fixtures/rh_trading_pairs.json with BTC-USD’s real 0.1. The row’s max_order_size is corrected to the measured 20.0000000000000000 at the same time (it read 10.00000000); the increments were already correct.

The false claim is corrected in packages/keel-broker-robinhood/README.md (§ “No published minimum order size” → “The minimum order size is published, but only on some pairs”), in RobinhoodTransport.get_trading_pairs’ docstring, and in the two suite tests that asserted the field’s absence. Dated documents under docs/experiments/ are left alone. #198’s prerequisite list still needs its comment corrected — min_order_amount is a lower-bound source for every asset keel trades, read as optional per pair.

D3 — regression test

test_a_key_only_a_LATER_element_carries_is_still_reported. Against the old shape_of:

>       assert len(diffs) == 1, f"a key only the second element carries went unreported: {diffs}"
E       AssertionError: a key only the second element carries went unreported: []
E       assert 0 == 1
E        +  where 0 = len([])

tests/scripts/test_robinhood_smoke.py:178: AssertionError
1 failed, 31 deselected in 0.05s
Also

…truncated — full description in #232.

Compliance & rails

docs(experiments): assess cTrader Open API and refuse it on charter grounds (#203)

cTrader came up as a candidate execution venue. This records the assessment and the answer — no — so the question does not get re-litigated from scratch. No adapter, no dependency, no entry point: the deliverable is the finding.

Closes #201.

The finding

cTrader Open API has no unleveraged spot-ownership primitive. It is not a spot venue with leverage bolted on — leverage, margin, short-symmetry and cash-only P&L are load-bearing in every core message type, and no configuration of the account or the API removes all four at once:

  • ProtoOATrader carries leverageInCents/maxLeverage as always-present account fields; ProtoOAPosition carries usedMargin/marginRate/swap as baseline fields on every position.
  • ProtoOATradeSide SELL opens a short — it is symmetric with BUY, not a disposal of something held. There is no “sell what you own” path.
  • There is no cash-balance-in-asset concept anywhere. Only a deposit-currency balance plus position P&L, so nothing ever represents “you hold 0.1 BTC”.
  • The swapFree “Shariah account” does not rescue it: it substitutes a daily rolloverCommission for swap, and touches neither the leverage, the short symmetry, nor the absent custody. It is the answer to one question out of four, and not the one that matters.

The KB already rules on this product class directly, which is what turns the argument from inference into citation: §56.1 as corrected by §66.3 — retail “spot” FX is “perpetually rolled to avoid the actual delivery of the currency,” with the interest differential charged for the deferral, so a “spot” label is not evidence of spot settlement. §66.3’s litmus test — “transfer of property, substantively or constructively” — is exactly what cTrader fails. §28.1–28.2 name CFDs outright in the gharar exclusion set.

What this changed about our own defences

The assessment was independently reviewed, and the review’s most useful finding was that the first draft described keel’s gates as stronger than they are. Those corrections are in this PR and matter more than the verdict did — a refusal argued from defences that do not hold is worse than no document:

  • Rail 17 is entries-only and one of two LIVE_STATE_RAILS, so it is skipped in paper. Its input is the operator’s attestation, not anything an adapter reports — it fails closed by default, but it is operator-honesty, not adapter-honesty.
  • Rail 19’s check does not fail cTrader. EUR-USD parses cleanly as a spot id; this is the two-segment residual guards.py already documents. The rail contributes its charter comment, not its grammar.
  • The curation screen is not a reliable gate against a CFD wrapper at all — AssetAttestation is keyed on the base asset with no field for an instrument wrapper, so a BTC CFD reuses BTC’s admitted spot attestation. Filed as #202 rather than papered over.

So the honest count is two gates plus the port’s own vocabulary, not three independent gates.

Market data, answered rather than dodged

cTrader’s market data is genuinely good, and every gate above is execution-side — so a read-only feed trips none of them and would not touch the broker port at all. The document says so and answers on cost/benefit instead: dealer-quoted marks rather than exchange prints, cross-venue basis against the Coinbase spot universe keel actually trades, an SDK with no release in ~2 years, and disproportionate onboarding. Not worth it today, but explicitly not charter-barred the way execution is — claiming otherwise would be the same overreach the review caught three times.

Evidence, stated honestly

A documentation review, not a live probe. No account was opened, no application registered, no connection made. Protocol claims cite help.ctrader.com/open-api/*; SDK-maintenance claims cite PyPI and GitHub. There is no re-runnable probe script here, unlike the Coinbase asset-class study, and the document says so rather than implying empirical evidence it does not have.

Why nothing was built

…truncated — full description in #203.

docs(experiments): assess Trade Nation; refuse it on the instrument, not the transport (#210)

Closes #204.

Trade Nation came up as a candidate execution venue. This records the answer so the question is not re-litigated from scratch, following the house form set by docs/experiments/2026-08-05-coinbase-asset-class-feasibility.md and the cTrader assessment in #203.

Verdict

No — and the refusal is structural, resting on the instrument rather than the transport.

What distinguishes this from cTrader is that a technically usable Python order path does exist here. MT4 accounts are self-serve, Expert Advisors are an advertised feature (Automate using Expert Advisors (EAs) on their own platforms page), the live server name is public, and open-source MQL4-to-Python bridges are a solved problem. A determined engineer could have Python placing live orders at Trade Nation inside a weekend. So the refusal cannot rest on “there is no API” — and the document deliberately builds that counter-case at full strength before answering it.

It is answered on the only ground that matters: every keel gate is a gate on what is traded, not on how the order arrived. A bridge does not launder gharar.

Trade Nation is CFD and spread-betting only — leveraged, financed, non-deliverable, short-symmetric, with no rights of ownership (UK Client Agreement cl. 3.5). There is no unleveraged spot ownership anywhere in its product range, so rails 17, 18 and 19 and the curation screen each reject it independently, and rail 19’s own comment records that spot-only is the charter with no config field to widen it.

On the missing API

The negative is established by enumeration, not by a failed search, so it is falsifiable rather than an absence of evidence: 1,423 sitemap URLs plus 160 support URLs with zero connectivity pages, seven direct path probes, and “API” appearing zero times on six key pages. There is also no vendor to appeal to — FINSA EUROPE LTD is TRADE NATION FINANCIAL UK LTD (Companies House 07073413, renamed 2022), so com.finsa.* is a legacy in-group namespace and TN Trader is proprietary. No white-label API doc exists to read.

Scope

Documentation review only. No account was opened; api.tradenation.com is a private backend for their own apps and was deliberately not probed. Builds nothing — no adapter package, no dependency, no entry point, no conformance fake.

Review

Independently reviewed by a fresh reviewer before this PR: verdict APPROVE WITH CHANGES, all 6 required changes applied. The reviewer verified all 55 file:line citations (zero errors after an earlier fix pass corrected 7) and 9 of the KB quotations verbatim, and independently falsified two details of the enumeration — which the method is designed to permit. Corrections applied cover the stale §66.7 crypto claim (since partially closed by §71/§72/§85), real platform-specific instrument names, the /api/ 302, dividend adjustments, and the primary-source ownership clause. The reviewer’s strongest attack — the swap-free/“Islamic” account — is now pre-empted in the document: it removes the riba on the rollover and leaves gharar and cash-settlement-no-delivery untouched.

Note on the rails’ limits

The document volunteers where the rails stop rather than overstating them. A cosmetic rename to EUR-USD is a faithful transliteration that clears rails 18 and 19 outright, leaving only rail 1 and the curation screen — and #202 is why the screen is not claimed as an unconditional defence. That residual is stated plainly rather than papered over.

feat(compliance): attest the instrument wrapper, not just the underlying (#223)

Closes #202.

The gap

AssetAttestation is keyed on asset — a base-leg symbol — so it can only ever describe the underlying. The honest attestation for the underlying of a BTC CFD is character-for-character BTC’s existing, already-admitted spot one (sector=payments, backing=native, pays_yield=False), so the screen admitted it. Swap financing, leverage and counterparty exposure are properties of the contract, and no amount of care taken over the asset claim could ever surface them.

The decision

Keep AssetAttestation exactly as-is — a per-asset claim about the underlying — and add a separate InstrumentAttestation keyed per (venue, product_id) carrying a wrapper. Admission requires both. Sector/backing/yield are facts about the underlying; “what contract is this listing” is a fact about a venue’s product.

Keyed on product_id, not (venue, asset), because Coinbase — the one venue keel already uses — lists both BTC-USD (spot) and BTC-PERP-USD against the same base leg. A per-asset wrapper claim would be factually wrong today, not merely imprecise once a second venue lands.

The wrapper is attested, never computed. The id’s shape cannot answer it: a cTrader CFD spells itself BTC-USD and parse_spot_product_id reads that as well-formed spot — that is the gap. The venue’s own product_type is its self-report about its own product, which makes it excellent input to the human’s source and unacceptable as the claim itself.

spot_instrument (the id’s grammar) and instrument_wrapper (the human’s claim) are complementary and deliberately not merged — both fire for a derivative-shaped id attested as spot, so neither failure can hide behind the other.

Rejected alternatives
  • Add a wrapper axis to AssetAttestation — it is keyed per asset, so it cannot express that BTC-USD and BTC-PERP-USD are different contracts on the same venue.
  • Auto-attest from Coinbase product_type — that is the venue self-reporting on its own product. It is evidence to cite, not the claim.
  • Derive the wrapper from the id shape — a CFD spells itself exactly like spot. This is precisely why the gap exists.
  • A per-trade rail in guards.py — the screen is a curation gate by charter, and guards.check has no broker/venue handle. That is the dead-gate pattern capabilities.py explicitly warns about.
  • Backfill spot for the current allowlist — see below.
⚠️ Accepted consequence: REJECT until attested

keel assets screen now reports REJECT (instrument_wrapper: UNATTESTED) for every product — including BTC-USD, ETH-USD and PAXG-USD — until the operator runs keel assets attest-instrument once per product.

This is the fail-closed default and it is intended. It does not affect live trading: screen_asset has exactly one production caller, and rail 1 gates live buys on config.allowlist directly, not on the screen.

There is deliberately no backfill, no default-to-spot, and no auto-attestation. Seeding spot rows for currently-allowlisted products would fabricate exactly the claim this gap is about, for the three products the project is most likely to stop questioning. The failure message names the exact command that fixes it — that is how the operator is told.

$ keel assets attest-instrument --product BTC-USD --wrapper spot \
    --source "coinbase product spec" --attested-by "<name>"
Not waivable

WAIVABLE_CRITERIA stays exactly frozenset({"history"}), as #202 requires — a waiver here would waive the charter, not a threshold. A stray screen_exceptions row for instrument_wrapper is dropped by the up-front filter and pinned by a test.

instrument_wrapper is deliberately not in DATA_DERIVED_FAILURES: like settlement, it reads an attestation and never touches candles, so it stays a real, assessable verdict at zero bars.

Changes

…truncated — full description in #223.

Research & validation

research: are the 11 CTS factors collinear? — momentum cluster refuted, trend cluster real and small (#224)

Closes #208.

Research only. DEFAULT_WEIGHTS, every factor, every gate and every threshold are untouched — no scoring behaviour changes in this PR. The issue is explicit that this is “not a drive-by tweak to the scoring weights”, and it isn’t one.

The answer
the question #208 askedanswernumber
momentum cluster {rsi_extreme, rsi_divergence, deceleration} is collinear?no — refutedmean within φ −0.018 vs +0.025 background
trend cluster {condition_aligned, ema_fan_aligned} is collinear?yes, mildlymean within φ +0.190 vs +0.018 background
does collinearity inflate the score?barelyVar ratio 1.161 → ≈8.6 effective independent factors of 10
largest collinearity anywherenot a pre-declared clusterdeceleration × candlestick_pattern φ +0.254

rsi_extreme and rsi_divergence co-occur ZERO times in 6,822 bars (Jaccard 0.000, lift 0.00). They read the same indicators.rsi(closes) array and are wired to fire in disjoint states — a bullish divergence needs RSI making a higher low, which is by construction off the oversold extreme. Sharing an input is not sharing an answer. Collapsing that cluster, the remedy #208 proposes, would destroy information rather than remove duplication.

The trend pair is real (10–22× background, stable on all five assets, reproduced at +0.201 on a 27× larger sample) and small: 3.6% shared variance over 4 of 14 raw points.

This is a negative result on the headline hypothesis and it is reported as one. #208’s structural argument is a correct description of the wiring and a wrong prediction about the output.

N, and how it was obtained

The issue’s proposed data source — the signals table — holds one row in keel.db and zero in keel-live.db. The sample had to be reconstructed, which is possible because engine.assemble_cts_context is a pure function of (setup, candles) and keel.db.candles holds 611,176 bars back to 2021.

armpopulationgranularitywindowN
1 (headline)every barONE_DAYexpanding6,822
2 (replication)every barONE_HOURrolling 500186,725
3 (window sensitivity)every 20th bar, BTCONE_HOUR250/500/10002,207 each
4 (contrast only)fired, gate-cleared signalsONE_DAYexpanding77

N = 6,822 = every cached daily bar after a 200-bar warm-up on the five live-allowlist assets (1,647 BTC + 1,647 ETH + 258 PAXG + 1,635 ADA + 1,635 XLM). Expanding window because that is literally what the live path passes (agent.run_once → repo.get_candles with no bounds → engine.evaluate), and find_levels/detect_phase read the whole list they are handed.

Both samples are reported and the unconditional one carries the conclusion. Restricting to fired signals conditions on Rule.detect() and the choppy/higher-TF/kill-zone gates, which are functions of the same regime state several factors read — a collider. Arm 4 demonstrates it rather than assuming it: P(condition_aligned) goes 0.275 → 0.818, P(ema_fan_aligned) 0.301 → 0.909, and the variance ratio falls below 1 (0.663) because conditioning truncates rather than decorrelates.

Multiple testing

45 pairwise tests in arm 1 (10 of 11 factors vary; seasonality is weighted 0 and hardcoded False), 36 in arm 2 (sr_touches is constant under a 500-bar hourly window). Holm–Bonferroni at α = 0.05 over exactly the pairs tested, not over 11·10/2. Each ledger row carries its own hypotheses_tested.

Then the write-up says plainly, rather than dressing it up: at N = 6,822, φ = 0.041 clears Holm; at N = 186,725, φ = 0.054 gives p = 1.9×10⁻¹¹⁷. 23 of 45 pairs are “significant”. Significance answers “is this exactly zero”, which nobody asked. No conclusion in the document rests on a p-value, and the caveats note that serial dependence between bars makes every p-value optimistic anyway.

The finding worth acting on is not collinearity

levels.is_round_number(price, step=Decimal("0.005")) treats step as an absolute half-cent. Coinbase quotes BTC/ETH/PAXG to two decimals, so price % 0.005 == 0 always and the check can never fail. Verified directly:

…truncated — full description in #224.

Docs, CI & tooling

feat(tooling): read-only live probe for the robinhood adapter’s fixture shapes (#216)

Adds scripts/robinhood_smoke.py — an operator tool that validates the Robinhood adapter’s assumptions against the live venue without placing an order.

Why

tests/broker_robinhood/ runs entirely against a canned transport, so it proves the adapter is internally consistent with fixtures we wrote ourselves. It cannot prove:

  1. that Robinhood accepts our Ed25519 signature (the signing tests verify against our own verify-key — circular w.r.t. the venue);
  2. that the endpoint paths are real (pinned against our reading of the docs, and #194 showed that reading can be wrong in both directions);
  3. that the response shapes are real — tests/fixtures/rh_accounts.json’s nested fee_tier_status drives every number in get_fee_summary and every Preview.est_fee, and nothing outside this repo corroborates it.
What it does

Five read-only GETs — accounts, trading_pairs, best_bid_ask, estimated_price, holdings — and a structural diff of each against its committed fixture, reported in both directions. A key the fixture invented is the dangerous one; a key the venue sends and the fixture omits is how a capability gets missed (fees_usd, #197, is exactly that shape).

Two safety properties, both tested

Read-only by construction. The guard is installed onto the transport’s _request, not wrapped around it. A __getattr__ wrapper looks correct and is not — the transport’s own methods call self._request and sail straight past it, so the guarantee would hold for the tests and not for the probes. That bug was written, caught by test_running_every_probe_issues_only_gets, and fixed; the test now pins it.

Shapes, not values. Every leaf is replaced by its type, so balances and holdings never reach a terminal, a CI log, or a pasted bug report.

It also pre-checks the private key’s length, because pasting the base64 public key Robinhood’s credential page asked for produces a 401 indistinguishable from a signing bug.

Not covered

The order lifecycle — state values, fee_charged, whether a cancel 200 confirms. No sandbox exists, so that needs a real order; tracked under #198.

Gates
ruff:   All checks passed!
mypy:   Success: no issues found in 216 source files
pytest: 2315 passed, 1 skipped   (+47 tests; skip is still the conformance candles probe)

Refs #215. Not shipped in the wheel — operator tooling, like migration_smoke.py.

ci: run the suite once per PR, not twice per merge (#220)

What

ci.yml triggered on both pull_request and push: branches: [main], so every squash-merge re-ran the full suite (lint, test, build identity) on content that had already passed minutes earlier as the PR run. That’s roughly double the Actions minutes for zero new information, since the merge commit’s tree is identical to what the PR run already tested.

  • Removed the push: branches: [main] trigger.
  • Added workflow_dispatch so the suite can still be run on demand against main (e.g. before cutting a release, or after changing branch protection settings).
  • Kept pull_request — that’s the gate that actually protects merges, and for a tool that moves money it’s the one worth paying for.
  • Rewrote the header comment: the old one justified running on push (“otherwise main can drift red between releases and the gate discovers it at the worst moment”), which is exactly the reasoning being traded away here, so it no longer belonged.
Why this is safe

release.yml already re-runs ruff + pytest as its own gate before it will build an artifact — “a red suite must never produce an artifact that could touch funds.” So main still cannot ship red even with nothing running automatically on push.

Residual risk (stated plainly)

A PR that was green against an older main can merge and turn out to be semantically red against the main it actually landed on — two independently-green changes that conflict once combined. Previously, the push-triggered CI run would have caught that immediately on merge. Now it surfaces at release time instead.

Branch protection’s “require branches to be up to date before merging” would close this gap by forcing every PR to re-test against the latest main before merge — but that’s deliberately not being relied on here, since it would reintroduce the double-run cost this PR removes (just moved to before-merge instead of after).

Validation
  • uv run python -c "import yaml; print(list(yaml.safe_load(open('.github/workflows/ci.yml')).keys()))" parses cleanly (PyYAML maps the bareword on key to True per YAML 1.1 — a pre-existing quirk unrelated to this change, confirmed identical on the pre-change file and on release.yml).
  • Confirmed no docs reference CI running on push/merge (docs/RELEASING.md checked); no doc changes needed.
  • Only .github/workflows/ci.yml touched — triggers and header comment only, job steps unchanged.

Closes #200

docs(strategy): entry_technique is a recorded label, not a sizing/stop control (#231)

Summary

keel/strategy/indicators_cts.py documented a risk-graded entry ladder — confirm_3bar = “smaller size, wider stop”, aggressive = “larger size, tighter stop”. Nothing in the codebase implements any of it.

entry_technique is computed in engine.py:144 and reaches exactly two destinations: the signals audit payload (engine.py:360) and a technique= field on an agent.enter_evaluated log line (agent.py:1166). git grep -nE "technique (==|!=|in )" -- '*.py' returns nothing outside tests. Position size and stop distance are identical whether CTS scores 2 or 12.

This PR removes the documented behaviour the code never had and rewrites the docstrings to describe what entry_technique actually is: a recorded label naming the confluence tier at signal time, kept for audit and later analysis.

Nothing changes at runtime. The diff is docstrings only:

  • low=5 / high=8 defaults: untouched
  • the three returned literals confirm_3bar / signal_candle / aggressive (persisted in the signals table): untouched, same spellings
  • DEFAULT_WEIGHTS and every call site: untouched

Wiring the ladder into sizing/stops remains possible later, but it is a live position-sizing change and needs its own evidence — backtest data showing graded sizing beats flat sizing, plus a rails review (sizing interacts with the per-order and per-day caps) — before it earns a docstring claim like the one this removes.

Closes #228

Test plan
  • uv run ruff check keel tests packages scripts — all checks passed
  • uv run pytest -q — 2444 passed, 1 skipped (matches main baseline exactly; no test count change since no behaviour changed)
  • uv run mypy — no issues found in 219 source files
  • Diff reviewed: docstrings only, zero logic change

ci: make CI manual-only, and add a SonarQube + Snyk workflow (#234)

Two related changes to the Actions setup.

⚠️ Before this can run: two secrets must exist

gh secret list is empty at both repo and org level. The new workflow will fail its first job until these are added at Settings → Secrets → Actions:

SecretWhere to get it
SONAR_TOKENSonarQube → My Account → Security → Generate Token
SNYK_TOKENSnyk → Account Settings → Auth Token (or snyk config get api)

The failure is deliberately made legible: a preflight job runs before either scanner and fails with an explicit message naming both secrets and linking the settings page, rather than letting Sonar emit “You’re not authorized” and Snyk emit a generic container auth error.

Two more first-run items, both documented in sonar-project.properties: sonar.projectKey is set to CodeGateSoftware_keel and must match the project as it exists on the Sonar server; sonar.organization is left commented out because SonarQube Cloud requires it and a wrong value fails more confusingly than a missing one.

1. ci.yml becomes manual-only
on:
  workflow_dispatch:

pull_request is removed; workflow_dispatch is untouched.

The accepted consequence, stated plainly: nothing automatically verifies a PR before merge any more — no lint, no suite, no build-identity check stands between a branch and main. A breakage introduced by a merge is discovered at release time, or by whoever next dispatches CI — not at merge time.

What survives is the gate inside release.yml: it re-runs ruff + pytest on every release before it will build an artifact, so a red main still cannot ship a wheel, a tag, or a release asset.

The header comment previously argued at length for keeping pull_request (“the gate that actually protects merges… worth paying for”). That argument no longer describes the file, so it has been rewritten. The concurrency block’s comment explaining github.ref for pull_request runs was also stale and is corrected.

🔎 A finding worth your attention: the main ruleset is inert

The task premise was that main has no branch protection. That is true in effect but not quite in fact, and the difference is worth flagging:

  • Classic branch protection: GET /branches/main/protection → 404 Branch not protected.
  • But there is an active repository ruleset named main (id 20701832), created 2026-08-11, which does configure 1 required approving review and two required status checks.
  • It matches no refs. Its conditions.ref_name.include is [], and authoritatively GET /repos/CodeGateSoftware/keel/rules/branches/main returns [] — zero rules in force on the branch.
  • Separately, its two required contexts — "Lint, Unit tests, Build" and "Integration Tests" — match no job this repo defines (keel’s only CI job is test). Even if the ruleset were pointed at main, those checks would never report and PRs would block indefinitely.

So no required status check compensates for the trigger change, and the comment in ci.yml says so accurately rather than claiming “no protection exists”. If the ruleset was meant to be live, it needs both its ref targeting and its check contexts fixed — happy to do that separately.

2. New code-quality.yml — SonarQube + Snyk

Modelled on TempTrak-Ingestion-Service/.github/workflows/code-quality.yml, adapted rather than transliterated. keel is Python 3.14 + uv with a 5-package uv workspace; the reference is a Node/TS monorepo.

on:
  workflow_dispatch:
  schedule:
    - cron: "0 6 * * 1"

Three jobs: preflight (secret check) → sonarqube and snyk in parallel.

Deliberate divergences from the reference

…truncated — full description in #234.

ci: gate merges on the test check, and verify the merge result (#238)

Adds push: branches: [main] back to ci.yml, keeping workflow_dispatch.

on:
  push:
    branches: [main]
  workflow_dispatch:
Why this is not the redundant run it was called in #200

#200 removed this trigger on the grounds that it re-ran content the pull_request run had already passed. That reasoning held while pull_request still existed. It does not now — #234 removed it, so nothing automatic runs at all.

It was also incomplete even then. A pull_request run tests the PR head; a push run tests main after the merge landed. Those diverge whenever two independently-green branches conflict semantically — each passes alone, the merge of them does not. Only the post-merge run observes the tree that actually resulted.

What this does and does not buy

Does: every merge is verified within minutes, against the real post-merge tree. Given three PRs in this repo have merged mid-flight while an agent was still pushing to them (#192, #218, #235), time-to-discovery is the practical problem, and this addresses it.

Does not: block a merge. It runs after. Blocking needs two things this repo lacks, and they must move together:

  1. a pull_request trigger, and
  2. a ruleset that actually matches main and requires the test context.

The ruleset named main exists but matches no refs (ref_name.include is empty; GET /rules/branches/main returns []), and its required contexts ("Lint, Unit tests, Build", "Integration Tests") match no job here. Pointing it at main without fixing the contexts would block every PR forever on checks that can never report. Tracked in #236.

release.yml is unchanged and remains the gate that matters for money — it re-runs ruff + pytest before building an artifact, so a red main still cannot ship.

Concurrency

Unchanged (ci-${{ github.ref }}). The comment is updated: back-to-back merges to main now cancel the older run, which is correct — the newest main is the only one whose result still matters.

Validation
  • YAML parses; triggers read {'push': {'branches': ['main']}, 'workflow_dispatch': None} (checked via d[True], since PyYAML follows YAML 1.1 where bareword on is boolean).
  • actionlint clean, exit 0.
  • Job steps untouched — the diff is triggers and comments only.

docs: explain the dashboard’s field names and the four money settings (#240)

Two documentation gaps, split along what the operator sees vs. what the operator edits.

TUI help (h overlay) — the vocabulary on screen

_equity_lines prints keel’s internal field names verbatim, and nothing on the dashboard explains them:

equity_state_mode: paper
high_water_mark: 11000
drawdown: total=0 (ceiling 0.2) weekly=0 (ceiling 0.1)
rail11 (drawdown breaker): ok
paper_cash_usdc: 11000

The v overlay adds sig blk ent exi err. A screen of zeroes reads identically whether keel found no setup or found one and a rail vetoed it — the difference between an idle deployment and a correctly-declining one.

Adds a Glossary section to the existing h overlay (after “Live balance”, before “Help mode”), in the existing _row/_note idiom: cycle, signal, sig / blk / ent / exi / err, paper_cash_usdc, equity_state_mode, high_water_mark / drawdown / rail11.

  • No new key binding.
  • Widest new line is 85 columns against the help’s existing 88, so nothing newly clips under _paint; scrolling is unchanged (161 lines total).
  • Four new tests in the existing style, section-scoped so an assertion can’t be satisfied by a word three sections away.
README — the settings in a file

Adds “How much money moves” after “Confirm vs. autonomy”, covering the four settings and, crucially, how they interact.

settingthe surprise
paper.starting_equity_usdONE-TIME seed, first paper run only (agent.py, the paper_trader.get_cash() is None branch). Editing it later resizes nothing, and no command clears the persisted paper_cash_usdc.
paper.monthly_contribution_usdcompounds against that small base
caps.max_exposure_usddual role — held-at-once ceiling and the live sizing equity proxy, so risk_pct is a fraction of it, not of real equity
rail 14’s monthly allowancelives in broker_subscriptions (DB, not config) — which is why it drifts out of step with the caps

The interaction is the point: sizing scales with equity (or the proxy), the rail-14 allowance is a fixed dollar figure. When they drift apart every setup is vetoed and the system looks broken while working exactly as designed. Recorded with the verified case — $11,000 equity, risk_pct: 0.01, a 3.35%-wide stop sizing to $3,284.67 against a $500/month allowance; reseeding at $500 sizes the same setup at $149.30. Plus the mechanic that makes it counter-intuitive: a tighter stop yields a LARGER position (size = risk ÷ stop-distance), which is how a 1% risk became a 30% position.

Verification

Every claim was read out of the source, not assumed. Notable precision fixes made during review: starting_equity_usd: 0 seeds from real mark-to-market equity (a value > 0 is an override, not a fallback); the sizing proxy applies to the live non-DCA ENTER path only — paper mode passes equity_override; the contribution is per UTC calendar month; and config.yaml does still supply rail 14’s tier catalogue and unattested fallback, so the DB claim is scoped to the in-force allowance.

Documentation only — no behaviour, default, or config value changed. pyproject.toml untouched; nothing under docs/experiments/ touched.

$ uv run ruff check keel tests packages scripts
All checks passed!

$ uv run pytest -q
2668 passed, 1 skipped in 31.65s
SKIPPED [1] packages/keel-broker-api/keel_broker_api/conformance/suite.py:254:
  adapter serves no granularity the suite could exercise

$ uv run mypy
Success: no issues found in 221 source files

2668 = the 2664 baseline + 4 new tests; exactly the one expected skip.

Other changes

docs(experiments): teardown quantcrawler.com for near-term and SaaS lessons (#193)

Competitive teardown of quantcrawler.com — a trading-automation SaaS (webhook→broker execution, a prop-firm risk layer, a trade copier, ~299 pages of programmatic SEO) — mapped onto keel, for both near-term engineering and a possible SaaS future.

Doc: docs/experiments/2026-08-09-quantcrawler-teardown.md

The finding that matters

The comparison surfaced a P0 in keel’s live path — though not the one it first looked like, and the doc records all three passes because the wrong answers are instructive.

A rails veto cannot strand a filled entry: rails 3/4/5/6/8/11/13/14/16/17 are is_buy-gated and cannot fire on a protective sell (rail 13 says so outright — “SELL is exempt”). At initial placement the bracket’s notional is strictly smaller than the entry that just cleared the same rails.

But if not result.placed (executor.py:772) also catches a broker rejection (min-size, precision, venue error), and that is reachable:

  • the entry fills, the bracket is never placed, and no order row is written (the insert is after the guard gate)
  • reconcile_open_orders iterates only status="pending" (reconcile.py:52), so _rebracket_or_escalate never sees it — it only heals brackets that were accepted and later died
  • keel-live-run.sh:74 gates on a UTC day-stamp — “the trigger count is catch-up BREADTH, not cadence” — so the window is ~24h, not 1h
  • keel kill halts new orders and closes nothing; there is no flatten
  • and _roll_stop’s CRITICAL position_unprotected is dead code live (rolls are unwired; tests/execution/test_executor.py:1415 is the tripwire). The reachable path emits only a WARNING.

Recommended fix: re-point _rebracket_or_escalate at the positions ledger (tranches whose bracket is absent or rejected) instead of status="pending". Reuses existing machinery, bounds the window to one cycle, needs no rail exemption. Plus an alert webhook (§1.2) — with a once-daily cycle and no flatten, a WARNING in a logfile on a headless Mac is the difference between a bad trade and a day-long unhedged position.

The doc explicitly rejects the two tempting fixes: exempting sells from rails that already exempt them (rail 2 is the only per-order magnitude check on the sell side — removing it leaves a qty bug nothing to hit), and auto-flattening into the same book that just rejected the order.

Also covered
  • Robinhood adapter: adopt their “refuse rather than silently substitute” stance — declare the quote-sized-entry gap in BrokerCapabilities rather than approximating with base quantity.
  • A latent gap: place_bracket passes the stop as entry with stop=None, so rail 9 never evaluates the bracket’s own stop level.
  • A testable critique of existing keel code: their “one indicator per category” rule challenges the 11-factor CTS, which carries RSI extreme + RSI divergence + deceleration — arguably three reads of one momentum axis.
  • SaaS: multi-tenancy blockers, the halal + research-discipline positioning, the free-calculator/data-asset GTM playbook, and the licensing question (keel decides what to trade, unlike their user-directed webhooks) that gates any paid multi-tenant launch.
  • Their trading content is SEO filler, and its one rigor claim — 50,000 trials/ticker with no multiple-testing correction — is precisely the failure mode keel/research/cscv.py and deflate.py exist to prevent. Their “TICK VALIDATED” results page shows byte-identical numbers across five different instruments.

No code changes; documentation only. All claims about keel’s behaviour are cited by file:line and were verified against source, including by an independent reviewer that refuted two earlier drafts.

docs(experiments): assess US equities and refuse on screening capability (#211)

Asks whether keel can add US stocks — from Coinbase, and if not, from another broker. The answer is no, and the binding gate is keel’s own screening capability rather than any venue. That makes stocks “not yet” where cTrader’s CFDs (#203) were “never”.

Feasibility only. No code changed, no adapter added, no dependency introduced.

Two refusals, each sufficient on its own

1. §71.6 rejects equity-representing instruments BY CAPABILITY. They require share-style business and financial screening keel performs none of. Broker-independent — no choice of venue fixes it and no probe can move it. asset_attestations cannot even express the answer: (asset, sector, backing ∈ {ayn,dayn,native}, pays_yield, source) has no vocabulary for debt ÷ market cap.

2. Coinbase’s equity surface has no market data at any price. A live re-run of the committed probe on 2026-08-09 (POST-guarded, create_order never called) found ONE_DAY candles n=0 across 21 trading days, empty market_open_price/market_close_price on regular sessions, and empty price/best_bid_price/mid_market_price on all 1000 products sampled. Order book and market trades 500. Preview 403s.

The venue premise collapsed and the verdict didn’t move

Coinbase does expose an equities order path — equity_order_metadata on create-order, quoted from their reference. The brief for this document said otherwise and was wrong. Had the charter gate been a formality, that correction would have flipped the answer. It changes nothing, which is what makes §2 rather than §1 the finding worth keeping.

Corrections against prior work
  • The 2026-08-05 study, on two counts: “order path refused by design” rested solely on a preview 403 and was never established; and the non-deterministic-universe finding no longer reproduces (stable cursor walk to 19,188 ids).
  • KB §65.10, whose DJIM description is faithful to Ayub but no longer describes DJIM after its 2023 screen retirements — a screen built against it would implement a retired methodology.
  • Robinhood: shipped official agentic equities trading 2026-05-27, so “crypto-only” is now false. But it is an MCP server requiring interactive OAuth PKCE consent with no client_credentials grant — unusable from keel’s headless launchd runner, and its single internal scope forecloses a data-only integration.
  • Schwab: the widely-repeated “AI-driven functionality” scrutiny is not Schwab’s language; it traces to a non-affiliated site created 2026-04-20.
AAOIFI SS 21 recorded as decided

Criteria quoted from the standard: debt ≤30% of market cap, interest-taking deposits ≤30% (not cash+securities as DJIM uses), prohibited income ≤5% of total income, tangible assets ≥30%. No receivables test, no averaging window. Two tensions left deliberately open: SS 21 reads as exemption from prohibition rather than permissive tolerance, and clause 3/4/6/1 would exempt keel’s ~24-day holds from purification by timing — satisfying the obligation by not being there on the measurement date.

Reviewer note

This document went through three research passes; the first two produced confident, well-formed claims that direct verification reduced, including one fabricated enum that survived inside the very section announcing its withdrawal. The audit trail and a short research-integrity section are in the document deliberately. Unverified claims are labelled as such — most importantly, create_order on an equity has never been called, and §71.6 applied to real US shares remains an inference.

docs(experiments): correct the equities assessment after independent review (#213)

Corrections to the equities decision record merged in #211, from an independent review that landed after the merge. The verdict is unchanged — keel still cannot add US equities, and the binding gate is still §71.6’s screening capability. Every change here is about accuracy of evidence and internal consistency.

Feasibility only. No code changed, no adapter added, no dependency introduced.

The two that mattered

Rails 18 and 19 were inverted in the verdict table. Verified against keel/execution/guards.py: rail 18 is what actually rejects Coinbase equities — quote_currency_of returns None on a 64-char hash — and its own comment calls it “an INSTRUMENT-CLASS gate wearing a currency’s clothes.” Rail 19 is a grammar check that a sane adapter’s AAPL-USD would pass. §5/E2 had this right; the verdict table was the sole outlier.

“Two independent refusals, each sufficient on its own” was overstated. It contradicted §7’s own “the whole verdict rests on this single step,” and refusal 2 is Coinbase-specific — the document concedes it “would be answered by choosing Alpaca.” Only refusal 1 answers the question as posed. The verdict stands on one leg, and now says so.

Evidence-integrity corrections
  • F1 — attribution, not fabrication. The decisive 2026-08-09 market-data measurements were attributed to the committed probe script, which contains no recent_trading_days, equity_trading_flags, or per-product price fields. They came from ad-hoc queries written for the fact-check. The numbers are not in doubt; their reproducibility was overclaimed. Now marked ⚠️ ad-hoc, not re-runnable — the one class of evidence a future reader cannot reproduce from the tree.
  • Two withdrawals were wrong and are restored: equit occurs 150 times in the spec, and named equity error codes exist (INVALID_EQUITY_TRADING_SESSION, PREVIEW_INVALID_EQUITY_TRADING_SESSION). Both were greppable the whole time.
  • A retraction retracted something true. EquityTradingSession is the real schema name; only its five value spellings were invented. Added as a fourth research-integrity rule.
  • 19,188 ids hit the script’s 20-page cap (probe.py:231) — termination was never established, only that the same walk now returns 47% more ids with zero drift.
  • “All 1000 products” is a first-page ~5% sample of a 19,188-id universe. Qualified at all nine sites.
AAOIFI §3 corrections — these change the implementation spec
  • Clause 3/19 is a regime selector, not a fourth screening ratio. Falling below 30% tangible assets does not forbid the share; it means the sarf and debt-trading rules must be observed. The Appendix says so explicitly. It was listed as a peer ratio and specced as an attestation field.
  • Clauses 3/17, 3/18 and 3/20 were omitted entirely — a screen built to §3’s spec would have shipped without them, including 3/18’s bar on shares of a corporation whose assets are entirely debts.
  • The “SS 21 contradicts itself on the 3/19 floor” claim was overstated: the footnote’s numerator is “tangible and cash assets” against 3/19’s “assets, benefits and rights” with cash in the denominator. Different measures. The quotation had stopped just before the words that show it.
External-source corrections

…truncated — full description in #213.

docs(config): document the robinhood probe’s key names in .env.example (#219)

scripts/robinhood_smoke.py (#216) has required ROBINHOOD_API_KEY and ROBINHOOD_PRIVATE_KEY since it landed, and #218 exercised it against the live venue twice, but neither PR added the names to .env.example. The only record of what to put in .env was the script’s own failure message — which you only see after you already went looking.

What changed

Two names and a four-line comment in .env.example. Nothing executable.

Why the comment carries its weight
  • Probe-only is the load-bearing fact. load_secrets does not read these. A reader who fills them in and expects the Robinhood adapter to come alive in a run has misread the state of the package. The adapter README says so at length; this is the one-line version at the point of use.
  • The seed encoding is called out because a PEM is the likelier guess, and getting it wrong fails with a character-length error that never names the cause.
Verification
  • uv run pytest tests/broker_robinhood tests/scripts/test_robinhood_smoke.py -q → 167 passed (unchanged; this PR touches no code).
  • Claims checked against source, not memory: the probe-only status against packages/keel-broker-robinhood/README.md:121-122, the seed encoding against scripts/robinhood_smoke.py:191.

v0.5.7

Voir cette version sur GitHub ↗

Built from 640c4961a411. Version binds to this hash: keel --version reports keel 0.5.7+640c4961a411 [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.5.7-py3-none-any.whl
keel --version

⚠️ 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

fix(assets): lower the discovery floor to 1M and pin it to the admission floor (#190)

The problem

assets discover’s 24h-volume pre-filter was 5,000,000 — five times the liquidity floor the admission gate actually applies (ScreenPolicy.min_median_daily_volume = 1,000,000). Discovery was stricter than the criterion it screens for, so it was hiding assets the gate would have accepted.

Measured, not assumed

On a live sweep (2026-08-08, deployment on v0.5.6):

floorcandidatesunsettled survivors
5,000,000 (old)91 (ZEC)
1,000,000 (new)38ZEC, DOGE, FET + 6 more to triage

Seven assets clear both mechanical gates — 4-year history and the real liquidity criterion — that the old floor never surfaced: FET, AVAX, ICP, ALGO, UNI, CRV, DOT. Each passes assets screen with attestation as its only rejection.

Two cases make the point:

  • FET — $2.94M/24h, invisible to the sweep, while measuring 4.8× the admission floor. It also ranks second of twelve on marginal independence (0.096 vs the book’s own internal 0.175), so the old floor was hiding the best new candidate available.
  • DOGE — $3.95M/24h, so it has never appeared in a default sweep, despite being ~30× the admission floor.

The floor, not the market, was the binding constraint on the candidate pipeline.

Why 1,000,000 specifically

It is not a fresh guess — it is ScreenPolicy.min_median_daily_volume. Setting them equal makes the pre-filter exactly as permissive as the gate, which is the most it can be without proposing assets that cannot pass.

The two remain different statistics — a 24h snapshot vs a median over history — which is what --probe-liquidity (#188) exists to reconcile. This pins the numbers, not the methods.

The drift bug found on the way

Three modules carried this default: cli.assets_discover’s option, admission.DEFAULT_MIN_QUOTE_24H_VOLUME, and screen.DiscoveryPolicy. Only the first two were pinned to each other — so a caller constructing DiscoveryPolicy directly, which assets_discover itself does, could have drifted from build_discover_report silently. Now all three are pinned, plus a fourth pin to the admission floor.

Scope

No behaviour change to the gate — same criterion, same floor, same verdicts. Only how many candidates a sweep surfaces for a human to consider. TDD: existing pin flipped and watched fail (5000000 != 1000000) before any source change. 2139 passed (+2), ruff and mypy clean across 203 files.

chore: bump to 0.5.7 for the lowered discovery floor (#191)

Bumps pyproject.toml and all four workspace members 0.5.6 → 0.5.7, plus uv.lock. Same shape as #189 and #184.

Why now

#190 lowered the assets discover 24h pre-filter from 5,000,000 to 1,000,000 and pinned it to ScreenPolicy.min_median_daily_volume. The deployment runs 0.5.6+9d7dedeb5b64 [release], so sweeps from ~/keel still use the 5M floor: 9 candidates instead of 38, with FET ($2.94M/24h but 4.8× the admission floor, and 2nd of twelve on marginal independence) and DOGE ($3.95M/24h but ~30× the admission floor) both invisible.

What 0.5.7 contains

Only #190 — the discovery floor change:

  • Default --min-volume-24h 5,000,000 → 1,000,000, set equal to the admission liquidity floor so a sweep can never be stricter than the gate it feeds.
  • Three modules carried this default (cli.assets_discover’s option, admission.DEFAULT_MIN_QUOTE_24H_VOLUME, screen.DiscoveryPolicy) and only two were pinned to each other — so a caller constructing DiscoveryPolicy directly, which assets_discover does, could drift from build_discover_report silently. All three are now pinned, plus a fourth pin to the admission floor.

No behaviour change to any gate, rail, rule or order path. The live agent behaves identically on 0.5.6 and 0.5.7; this only changes how many candidates a scout sweep surfaces for a human to read.

Verification
  • 2139 passed, ruff clean, mypy clean across 203 source files
  • keel --version → 0.5.7+7fa71e404b22 on this branch

v0.5.6

Voir cette version sur GitHub ↗

Built from 9d7dedeb5b64. Version binds to this hash: keel --version reports keel 0.5.6+9d7dedeb5b64 [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.5.6-py3-none-any.whl
keel --version

⚠️ 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

docs(config): correct the “unvalidated skip set” claim on the live allowlist (#185)

What

Re-assessment of a judgment carried in a live-money config, config.live-sandbox.yaml, directly above the real-money allowlist:

# The 5-trend Turtle set (same as the paper-forward + the walk-forward/PBO analysis).
# SOL/LTC/LINK are deliberately excluded -- they're the unvalidated skip set.
allowlist: [BTC, ETH, PAXG, ADA, XLM]

Verdict: the decision is right; the stated reasons were mostly wrong. The comment is rewritten to say what’s actually true, and the working is recorded in a new experiment doc. The allowlist is unchanged.

What the old comment got right

“Same as the paper-forward” is accurate at the rule level — keel.db’s paper-status rules are exactly 10/11/12 (BTC/ETH/PAXG) + 22 (XLM) + 24 (ADA), i.e. the live 5. That’s a coherent origin story for the exclusion and the PR now credits it.

What it got wrong
  • “The walk-forward/PBO analysis” describes work that does not exist for these assets. Every walk-forward/PBO/ablation run in docs/experiments/ stops at BTC/ETH/PAXG. trials-ledger.jsonl symbol census over 69 rows: BTC 32, ETH 33, PAXG 32, and 0 each for ADA, XLM, SOL, LTC, LINK. The walk-forward that exists tuned a parameter (turtle_breakout.py:113, entry_lookback 20 → 40).
  • “Unvalidated” doesn’t distinguish anything. None of the five live assets was validated either. keel-live.db rules 1–5 carry promoted_at IS NULL — seeded straight to live through the bypass rules seed warns about, and left in place 14 days.
  • No compliance asymmetry. SOL/XLM/LTC/ADA were attested in the same second (2026-07-23 12:15:12); LINK 160s later under the same governing source. All three excluded assets ADMIT keel assets screen and carry more daily history (1827 bars) than PAXG (456, on the list only via a documented waiver).
  • Wrong gate. The comment attaches a rule-level reason to an asset-level control; rail 1 (guards.py:369) is a flat membership test consulting neither attestations nor rule status.
The measurement nobody had run

The old comment guessed. Running keel rules backtest against a copy of keel.db settles the direction:

ruleassetn_tradeswin rateexpectancypf
21SOL1323.08%−12.410.259
23LTC50.00%−13.090.000
25LINK1520.00%−0.6650.630
22XLM837.50%+0.05611.44
24ADA650.00%+0.1215.52

All three excluded assets are negative-expectancy; LTC won none of five. The exclusion is correct — now for a reproducible reason instead of an invented one.

Stated honestly: n = 5–15 against min_trades = 100, in-sample, one window, candles 6 days stale for the excluded three. A directional check, not validation. None of the five clears the gate — which is why the allowlist is unchanged, and why the new comment explicitly warns against reading a positive expectancy as licence to add an asset.

What this PR does NOT do

The allowlist is unchanged. No asset attested, no rule promoted, no production database written — backtests ran against a scratchpad copy; ~/keel/keel.db’s mtime is untouched.

Verification
  • load_config yields an identical allowlist, caps and weights; a comment-stripped diff against main shows no non-comment differences.
  • python -m pytest tests/ -q → 2078 passed. (uv run pytest fails to import keel_core on a clean tree too — pre-existing, unrelated; this diff touches zero Python.)
Review corrections (second commit)

An independent adversarial review caught three false/unsupported claims in the first commit, two of them in the live config:

  1. “Nothing has traded” was FALSE — keel-live.db holds signals=1, a filled live order (BTC-USD BUY 0.000778 @ 64267.30) and an open position from the properly-promoted dca rule 6. The deployment is not inert and carries exposure. Removed.
  2. The min_trades argument used the wrong mechanism — rules promote gates on a candle backtest (commands/rules.py:180), not trade_outcomes. Conclusion unchanged, now resting on ADA n=6 / XLM n=8.
  3. “Never been backtested” was unprovable and self-contradicted the doc’s own point that the backtests table is dead schema (db.py:140, zero writers). Dropped.

Plus: credited the true paper-forward half, softened “same source” for LINK (its attestation deliberately classifies it a utility token), and made the entry_lookback quote verbatim.

Open operator items

Recorded in the doc, not actioned here:

  1. Deployment drift — ~/keel/config.live-sandbox.yaml still has the old comment; sync it.
  2. keel-live.db carries almost no compliance state — screening the live allowlist against a copy, 0 of 5 admit; SOL is ironically the only asset it admits. Harmless (rail 1 reads config, not the DB) but inverts the comment’s picture.
  3. Live-seeded rules left in place 14 days, against rules seed’s own instruction.
  4. Stale candles — re-run the backtests after keel fetch before relying on them.
  5. Two open attestation questions — LINK’s staking floor is emissions-funded; LTC has carried MWEB (opt-in confidential transactions) since May 2022.

docs: correct the SOL attestation note — the staking page supports pays_yield=0 (#186)

Follow-up to #185, correcting a claim I made there.

The correction

Open item 3 said the live-DB SOL attestation’s source (solana.com/staking) “sits awkwardly against pays_yield=0”. Fetching the page shows the opposite — it is direct evidence for the field:

“In order to earn staking rewards … the tokens in a stake account must be delegated to a validator.”

No rebasing, no automatic distribution to holders. Bare holding earns nothing, which is exactly what pays_yield=0 asserts.

The gap that is actually there

It is the wrong kind of source, not a contradictory one. AssetAttestation.source is documented as “where this was established — a URL, a standard, a scholar’s ruling.” A vendor product page establishes the mechanical fact; it cannot establish the ruling that a native L1 coin is Mal Hukmi, lawful to own and trade.

Every keel.db attestation carries both — KB source-86, the three-layer screen reasoning, and an explicit “Attests bare unstaked spot only.” This row carries only the mechanical half, and only implicitly. screen_asset cannot tell the difference: screen.py:249 checks source.strip() is non-empty and nothing more.

Provenance — closed, not left as “should be confirmed”

~/.zsh_history records the exact command at epoch 1786148982, one second before the row’s attested_at:

./.venv/bin/keel --config config.live-sandbox.yaml --db keel-live.db \
  assets attest --asset SOL --sector "layer-1 smart-contract blockchain infrastructure" \
    --backing native --source "https://www.solana.com/staking" \
    --attested-by "Elmehdi Aitbrahim"

Preceded by a placeholder attempt at 1786148907 and two keel fetch --products SOL-USD runs. Hand-typed at the terminal — not an agent, not the daemon, not the #185 investigation.

Also added
  • SOL now has two attestations with different evidentiary bases; which one governs depends on which --db a command runs against.
  • The page notes ~8% initial inflation decaying to 1.5%, so a bare holder is diluted rather than paid — not a riba yield, but whether that makes staking economically quasi-compulsory is a scholarly question, not one this file can answer.
  • None of it is live-reachable: SOL is not in the live allowlist and has no rule in keel-live.db, so rail 1 blocks any SOL intent regardless of the attestation.

Docs-only. No code, no config, no database touched.

docs: record the live-seeded rules as a deliberate, bounded standing exception (#187)

What

keel-live.db rules 1–5 (turtle_breakout on BTC/ETH/PAXG/ADA/XLM) have carried status = live with promoted_at IS NULL since 2026-07-24 — against rules seed’s own printed instruction:

⚠️ seeded at LIVE status, bypassing the promotion gate … Do not leave live-seeded rules in place afterwards.

Reviewed 2026-08-08. Kept deliberately. Now recorded rather than silent.

This applies the standard the project already set for PAXG in docs/experiments/2026-07-20-allowlist-screen-first-run.md:

The one thing not to do is leave it silently exempt because it was already there.

The exception was already load-bearing — it just wasn’t written down anywhere.

Why keep them

min_trades is 100 per rule, and this strategy cannot reach it. Measured by backtesting each rule over ~5.02 years of daily bars:

ruletradesrateyears to 100 trades
BTC132.59/yr~39
ETH132.59/yr~39
XLM81.59/yr~63
ADA61.19/yr~84
PAXG43.20/yr~31

Waiting for the gate is not a slower route to the same destination — it is no route. The sandbox exists to accumulate the live evidence the promotion gate demands and cannot itself generate. Demoting these rules would end that experiment without putting anything in its place.

What bounds the risk, since the gate didn’t

The caps (max_exposure_usd 200 total at once, max_per_order_usd 100), the eighteen un-overridable guards.py rails, rail 1’s allowlist, and rail 14’s monthly allowance.

The bypass is of the evidence gate, not the safety rails — separate mechanisms, and only the first was skipped.

What it is not

Not a precedent for admitting assets. Not a reason to raise caps. Not a claim these rules are validated — no walk-forward or PBO run has ever covered ADA or XLM, and none of the five clears any promotion axis (see #185).

Revisit triggers

Named, rather than an open-ended pass: caps rising above this sandbox’s few-dollars-of-damage scale; the rule set changing; a rule trading materially more often than the table above; or the sandbox being treated as evidence for anything beyond itself.

Where it’s recorded

Two places an operator actually reads:

  • config.live-sandbox.yaml — a block beside the allowlist it applies to.
  • docs/go-live-runbook.md §3 — a new “Standing exception” subsection. §3 previously documented the act of short-circuiting the gate but not the ongoing state.
Verification
  • load_config yields an identical allowlist, caps and weights; comment-stripped diff against main is empty.
  • python -m pytest tests/ -q → 2078 passed.
  • No database written, no rule status changed, no config value altered.

docs(experiments): build the §80.16 independence harness; measure cross-asset (#188)

What

research/independence.py has implemented §80.16’s five measurements since PR #103, but nothing in the repo drove them over two real rules. The 2026-07-20 cross-horizon run was ad hoc and left no script behind, so every future between-family question started from zero. This adds the script, plus its write-up and three diagnostic_only ledger rows.

Three passes
passvariesmean P&L corr (closed)outcome
between-family (turtle vs rsi_meanrev)rule family—degenerate, arm B never trades
cross-horizon (40/20 vs 80/40)lookback0.508reproduces PR #103 exactly
cross-asset (turtle vs itself, other asset)the underlying0.011the finding

The between-family pass is degenerate, not low. On daily bars rsi_meanrev takes zero trades on all five allowlisted assets — oversold=20 on a daily RSI(14) is near-unreachable (PAXG and XLM never print one). Arm B is settled-refuted anyway (§74.3), so it could never have tested candidacy; it is a shakedown.

The calibration is why the rest is trustworthy. BTC 0.802, ETH 0.934, median entry gap 0 days — exact reproduction of 2026-07-20-horizon-independence.md. It also settles which P&L convention PR #103 used, which that write-up never states: closed reproduces the published figures; mark-to-market gives 0.813 / 0.759.

Cross-asset is the finding. 0.011 / 0.175 / 0.144 against cross-horizon’s 0.508 / 0.585 / 0.510, same method, same assets. Asset breadth is roughly an order of magnitude better than horizon breadth as a source of independent evidence. §79.1 redirected the frequency plan to breadth without saying which; this says which. PAXG stands out — zero Jaccard against ETH, ADA and XLM.

This also tests an assumption 2026-07-20-minbtl-sizing.md’s allowlist case rested on: that added assets multiply trades/year roughly linearly, true only if those trades carry independent information. At the measured correlations they largely do.

Confidence

Arm A reproduces the shipped trade counts exactly (13/13/4/6/8), so the harness is wired to the real rule. Correlations rest on 4–13 trades per asset with no confidence intervals — the direction is far more robust than any individual cell. Caveats are in the write-up, including that this window had crypto broadly trending together (§79.16).

Ledger

Three rows, all diagnostic_only / a_priori, keel trials verify → chain intact. M 69 → 72, N_decisions unchanged at 30, so MinBTL is untouched. The cross-asset ρ̄ is deliberately not substituted into §78.2’s N̂, which corrects a trials count and is a different quantity.

Scope

⚠️ Corrected after merge. This section originally read “docs/experiments/ only — no library code, no config, no rule, no gate.” That was true when written and false at merge: a second commit was pushed to this branch before it squashed, so a code change shipped inside this PR. Corrected here rather than left to mislead, because scripts/release_notes.py inlines this body into the 0.5.6 release notes.

The research half is docs/experiments/ only. ruff clean; docs/ is outside the project’s mypy scope (files = ["packages", "keel", "tests"]), matching the existing 2026-08-05-coinbase-asset-class-probe.py.

Also shipped in this PR — fix(assets): the discovery pre-filter now measures what the gate measures

assets discover pre-filtered on the venue’s reported 24h quote volume; assets screen admits on the median of volume × close over cached history. Same units, different statistics, and the gap changes outcomes in both directions:

  • BICO was shortlisted on a reported $12.81M/24h, then rejected by the screen at a median daily volume of 108,004 — 9× under the floor, a 227× gap.
  • DOGE was nearly dropped for sitting 1.7% below the $5M discovery floor when, on the admission criterion, it is ~30× above it.

screen.median_daily_quote_volume is now the single definition of the statistic — cli._market_facts had it inline, and a second copy next to discover would drift, which is the bug. assets discover --probe-liquidity samples 180 days of daily candles per candidate (one extra request, same cost model as --probe-history) and marks LOW/ok against the same floor screen_asset applies.

Against the live venue it catches BICO (36,137) plus two the old filter passed through: IMU (71,713) and GWEI (440,864) — three of the top fourteen by 24h volume are sub-floor on the real criterion.

An estimator, not a verdict, and the output says so: 180 days recent vs the screen’s full cached history. ZEC probes 33.1M against a full-history median of 1.23M — a 27× spread from a recent surge. ok means “worth pulling candles for”, never “will be admitted”. No behaviour change to the gate: same criterion, same floor, same verdicts. Tests: 2137 passed, ruff and mypy clean.

chore: bump to 0.5.6 for the discovery liquidity probe (#189)

Bumps pyproject.toml and all four workspace members 0.5.5 → 0.5.6, plus uv.lock. Same shape as #184.

Why now

assets discover --probe-liquidity and screen.median_daily_quote_volume landed on main in #188 but are in no release, so the deployment — running keel 0.5.5+7b02bfd8fbbc [release] — cannot run them. The keel-asset-scout skill has been updated to make the probe the first step of every sweep, which fails outright against 0.5.5. This closes that gap.

What 0.5.6 contains

The discovery pre-filter now measures what the admission gate measures. assets discover filtered on the venue’s reported 24h quote volume while assets screen admits on the median of volume × close over cached history — same units, different statistics, and the gap changes outcomes in both directions:

  • BICO was shortlisted on a reported $12.81M/24h and then rejected by the screen at a median daily volume of 108,004 — 9× under the floor, a 227× gap between the two numbers.
  • DOGE was nearly dropped for sitting 1.7% below the $5M discovery floor when, on the admission criterion, it is ~30× above it.

screen.median_daily_quote_volume is now the single definition of the statistic (cli._market_facts had it inline; a second copy next to discover would drift, and that drift is the bug). --probe-liquidity samples 180 days of daily candles per candidate — one extra request, same cost model as --probe-history — and marks LOW/ok against the same floor screen_asset applies.

Against the live venue it catches BICO (36,137) plus two the old filter passed straight through: IMU (71,713) and GWEI (440,864). Three of the top fourteen by 24h volume are sub-floor on the real criterion.

It is an estimator, not a verdict, and the output says so: 180 days recent vs the screen’s full cached history. ZEC probes 33.1M against a full-history median of 1.23M — a 27× spread from a recent surge. ok means “worth pulling candles for”, never “will be admitted”. A recent window also makes a false LOW unlikely for a currently-active asset, which is the safe direction for a pre-filter.

No behaviour change to the gate itself — same criterion, same floor, same verdicts.

Verification
  • 2137 passed, ruff clean, mypy clean across 203 source files
  • keel --version → 0.5.6+9dcccadb6939 on this branch
Note on #188

#188 squash-merged the code change together with the docs/experiments harness, so its title and body describe only the research work. Its body is what scripts/release_notes.py will inline for 0.5.6, so it has been edited to describe the code change that actually shipped in it.

v0.5.5

Voir cette version sur GitHub ↗

Built from 7b02bfd8fbbc. Version binds to this hash: keel --version reports keel 0.5.5+7b02bfd8fbbc [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.5.5-py3-none-any.whl
keel --version

⚠️ 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(rules): keel rules add — a proposed parameter set’s path to a backtest (#182)

A scout produces rule-parameter proposals (proposals/*-param-proposals.json: {"kind": "turtle_breakout", "params": {"entry_lookback": 55}}). Until now none of them could be evaluated. keel rules seed builds rows only from each kind’s constructor defaults, and no CLI accepted custom params, so putting a proposal in front of keel rules backtest meant hand-writing Python against Repository.insert_rule. A proposal that cannot be measured is one that gets adopted on argument instead of on evidence.

keel rules add --kind turtle_breakout --product BTC-USD --params '{"entry_lookback": 55}'
added rule 3: turtle_breakout BTC-USD status=candidate
  params: {"adx_period": 14, ..., "entry_lookback": 55, ..., "target_rr": "6"}
  note: 1 other rule(s) already exist for turtle_breakout/BTC-USD -- this is allowed
        (comparing parameter sets is the point), but backtest the right one:
    [1] status=candidate entry_lookback=40
next: keel rules backtest 3
candidate, always, with no flag to say otherwise

The row is inserted at candidate and there is deliberately no --status. candidate is the lifecycle floor: the row must still clear rules backtest and rules promote before it can reach paper, let alone live. That single property is what makes it safe to let un-vetted JSON — from an operator’s keyboard or a scout’s file — into the rules table of a system trading real money. rules seed --status live exists for the supervised live-order test; a command whose entire input is un-vetted parameters must not have its equivalent. Asserted two ways: the command exposes no status parameter, and --status live is rejected.

One coercion boundary, not two

rules.params round-trips through json.dumps/json.loads, so a stored param is JSON-plain while the constructors want Decimals, a Granularity and tuples. agent._build_rule already owned that conversion. --params JSON has the identical problem in the identical direction, so rather than grow a second table, _build_rule was split:

  • agent.build_rule_from_params(kind, params) — the coercion + construction boundary,
  • agent._build_rule(row) — a thin DB-row adapter over it that adds rules.id.

rules add calls the same function the agent cycle and the backtester call. Two tables that can drift is the failure mode being avoided: the symptom of the drift is a Decimal/float TypeError raised deep inside a rule’s arithmetic, mid-backtest, far from the params that caused it.

Refused before anything is written

Everything below the first line of the command validates; insert_rule is the last statement that can run. Nothing is written on any refusal path.

inputoutcome
--product XLM-28AUG26-CDE / BTC-PERP-USDrefused — rail 19 shape (via parse_products_option, exactly as rules seed)
--product BTC-EURrefused — “settles in EUR”, rail 18
--product btc-USDrefused — “did you mean BTC-USD?”, never silently uppercased
--kind turtlerefused — naming RULE_REGISTRY’s kinds
{"cadance_days": 7}refused — the constructor’s TypeError, plus the accepted kwargs read off its signature
{"cadence_days": 0}refused — Dca’s own ValueError, “cadence_days must be positive”
not JSON / [7] / a product_id disagreeing with --productrefused

The params are validated by construction: RULE_REGISTRY[kind](product_id=..., **params) is actually built, and what is stored is .describe()’s params — not the raw JSON — so the row is exactly what agent._build_rule reconstructs. A row that stores but cannot rebuild is worse than a refusal: it fails later, inside a backtest or a cycle.

Construction is not enough, and that is not a theory

Two of the four rule kinds (RsiMeanReversion, PullbackContinuation) validate nothing in their constructors, so “validate by construction” is not a filter for them at all. Five shapes were found that construct, store, rebuild — and only then fail, inside the backtest the row was added for. Each was reproduced before being closed:

paramswhat construction didwhat happened next
{"oversold": "10.0"}accepted (plain dataclass, no validation)TypeError: can only concatenate str (not "int") to str in detect()
{"lookback_days": 90.5} / 1e400accepted, > 0 passes (inf is positive)TypeError: slice indices must be integers
{"oversold": null}accepted — coercion passes None throughfloat < None
{"ema_periods": "abc"} / ["8","20","50"]tuple("abc") char-splits to ('a','b','c'); quoted numbers survive as stringsTypeError in ema_fan
{"budget_usd": Infinity}Decimal('Infinity') <= 0 is False, so Dca’s budget guard passes itno meaningful backtest; the stored row is not valid JSON
{"granularity": "ONE_DAY"} (pullback)accepted, then dropped — describe() does not carry itrebuilds at the ONE_HOUR default: backtest numbers for a rule on a different candle series

All six now refuse, naming the offending params, before anything is written. Each check is derived from the rule itself — the constructor’s own default type, its own describe(), and agent.coerced_param_keys for “may this one be quoted?” — so a rule that changes a field’s type or starts persisting one needs no edit here. Quoting is the likeliest typo in hand-copied JSON precisely because it is correct for the Decimal params.

Deliberately not checked: whether a value makes economic sense. A negative atr_stop_mult is still accepted. That judgement belongs in the rule’s constructor, where every caller gets it (Dca already validates its own three), and re-stating it in a CLI command is exactly the second, drifting copy this change is otherwise careful not to create. A candidate rule with a nonsense parameter answers for itself in the backtest it has to pass.

Duplicates are the use case; the allowlist is a note, not a veto

No idempotency skip — comparing two parameter sets for one (kind, product) is the entire point. Existing rules for the pair are reported with their ids, statuses, and only the params that differ from the new row (rules list remains there for the full picture; making the operator eye-diff thirteen identical params at the moment they must pick an id is how the wrong id gets backtested).

A product outside config.allowlist is added and said out loud, not refused — backtesting SOL before deciding whether to admit it is the intended workflow, rail 1 still stands between it and any order, and a candidate rule never trades regardless. The note quotes rail 1’s own _asset key so it cannot disagree with the rail it is previewing.

Verification

./.venv/bin/ruff check keel tests packages clean, mypy keel packages clean, 2122 tests pass (2085 → +37). Every new test was watched fail for its own reason — against a stub command with the right option surface, so each one failed on its own assertion rather than on an import error.

The refactor of _build_rule is a pure extraction, checked line by line against the original and re-run against all four of its existing callers (agent.run_once, cli.simulate, insights.build_rule_track_record, rules backtest): same coercions, same order, same ValueError. The branch was also put through an adversarial review pass that hunted specifically for rows that store but do not run; the last two rows of the table above came out of it.

Read-only w.r.t. the exchange: no network call, no broker, no confirmation gate — one local rules row, exactly like rules seed.

fix(rules): refuse container params, typo’d Literal choices, and empty —params (#183)

Six findings from an independent adversarial review of #182, all reproduced on main before being fixed here. #182 itself stands — the candidate floor, the ~45 refusal paths, and the build_rule_from_params refactor were all verified solid. These are the gaps that review found.

F1 (HIGH) — _param_type_mismatches had two holes

Reproduced on main:

{'oversold': '10.0'}        refused=True    <- string caught
{'oversold': [1, 2]}        refused=False   <- LIST falls out of the elif chain
{'oversold': {'a': 1}}      refused=False
{'adx_threshold': []}       refused=False
{'stop_method': 'banana'}   refused=False   <- Literal choices never checked
{'entry_zone': 'banana'}    refused=False

(a) A JSON container supplied for a scalar param fell out of the chain entirely. {"oversold": [1,2]} is the same param and the same failure as {"oversold": "10.0"} and {"oversold": null}, both already refused. End-to-end on a copy of a real DB: rules add --kind rsi_meanrev --params '{"oversold": [1,2]}' → added rule 28 → rules backtest 28 → TypeError: '<' not supported between instances of 'float' and 'list'.

(b) A param’s declared Literal choices were never checked.

F2 (HIGH, raised from MEDIUM) — a typo’d Literal silently backtests a different rule

The worse of the two, because it does not crash. PullbackContinuation dispatches on == "ema_touch" and validates nothing, so an unknown value picks a branch by fallthrough. Measured on real BTC-USD hourly candles (4000-bar slice, this branch, rule constructed directly):

entry_zonetradesexpectancy
ema_touch (default)11-906.0013075636…
ema_band7-901.8626775771…
banana7-901.8626775771…
stop_methodtradesexpectancy
fixed (default)11-906.0013075636…
atr13-1134.3810236435…
banana13-1134.3810236435…

Identical to the fallthrough branch to the last digit. An operator who fat-fingers ema_touch reads ema_band’s numbers under the name they typed — and rules promote re-runs the backtest against that same stored row, so the row can advance toward live carrying a parameter nobody chose. Same class as the granularity finding #182 already refuses.

RsiMeanReversion does raise on an unknown stop_method — but from _compute_stop, i.e. at detect() time, on a row already written. #182’s docstring claim that construction catches “a value its own validation rejects” was false as written; corrected here.

The choices come from typing.get_type_hints(rule_cls.__init__), never a list kept in the CLI. from __future__ import annotations leaves inspect.signature’s annotations bare strings — mutating the fix to use inspect.signature instead kills 4 tests.

signal_patterns now declares its accepted names as SignalPattern, a Literal beside the matcher that implements them. That closes the inconsistency the review flagged: refusing signal_patterns: [] (“never signals, reads as a rule that does not work”) while accepting ["nonexistent"], which is that same rule with extra steps. A drift test pins the Literal to the names _match_signal_pattern actually handles.

F3 (LOW-MED) — --params '' silently meant “use the defaults”

json.loads(params_json) if params_json else {} could not tell “flag absent” from “flag given but empty”. --params "$(jq -c .params proposal.json)" yields "" when the key is missing or jq errors → a defaults row written, added rule 32 printed, and the operator backtests the stock rule believing it is their proposal. Now is None vs "", with an explicit refusal that names the likely cause.

F4 (LOW) — the Infinity refusal was factually wrong about its own worked example

budget_usd is a _DECIMAL_PARAMS param, so the stored value is the string "Infinity" — perfectly valid JSON, not the bare token the message claimed. And that is worse, not better: it rebuilds silently into Decimal('Infinity') that no positivity guard rejects and that propagates (size_usd=Decimal('Infinity') with nothing raising). Only float params (lookback_days: 1e400, volume_mult) emit the unparseable bare token. The refusal was right; the reason is now given per param and is true for each.

F5 (LOW) — _params_delta reported schema drift as parameter difference

Before, against a real pre-existing row:

[10] status=paper entry_lookback=40, min_volume_filter='<absent>', s1_filter='<absent>',
     volume_ma_period='<absent>', volume_mult='<absent>'

Four of five “differences” are params that did not exist when row 10 was written, burying the one under test. After:

[10] status=paper entry_lookback=40 [+4 param(s) that row does not carry, so not comparable:
     min_volume_filter, s1_filter, volume_ma_period, volume_mult]
F6 (TRIVIA) — coerced_param_keys’ granularity branch had no test

Mutating it killed zero tests. Pinned rather than removed, and one correction to the finding: the branch is not “unreachable” — it executes for both rsi_meanrev and pullback_continuation. It was merely untested.

I first justified keeping it by claiming that dropping it would leave a correct "timeframe": "ONE_DAY" refused. I checked that claim and it is false — Granularity subclasses str, so the value passes the string check either way, and removing the branch changes no accept/refuse outcome at all. (That is the same class of error as F4, so it did not get to survive in the fix for F4; see deb76a4.)

What the branch actually carries is operator-visible output. rules add prints this set verbatim as a quoted value is right only for [...], so a hint omitting timeframe would tell the operator the opposite of the truth about the one param whose whole job is to arrive quoted. That is now what is pinned, alongside a structural test against the tables the coercion itself reads. Removing the branch kills 3 tests.

Not done, deliberately

No economic range checks in the CLI. rsi_period: 0 still constructs (→ ZeroDivisionError at backtest) and atr_stop_mult may still be negative. Those belong in the rule constructors where every caller benefits, not in a CLI copy that would become the second drifting table the one-boundary requirement exists to prevent. Follow-up: TurtleBreakout and RsiMeanReversion should validate their own fields, as Dca already does.

Verification

Every fix mutation-tested; each is load-bearing:

mutationtests killed
container check disabled1
scalar Literal choices check disabled2
sequence element choices check disabled1
--params reverted to truthiness1
_params_delta reverted to key-union1
coerced_param_keys granularity branch removed2
a SignalPattern name dropped1
Infinity reason collapsed to one blanket claim1
float/Decimal reasons swapped2
get_type_hints → inspect.signature4
coerced_param_keys granularity branch (after the F6 correction)3

Gates: ruff clean, mypy clean (203 files), pytest 2134 passed (2122 on main, +12).

chore: bump to 0.5.5 for keel rules add (#184)

Version bump only — no code changes. Ships #182 and #183.

Patch: no schema change, no rail change, nothing that alters what the agent trades or when.

What it ships

keel rules add --kind --product --params inserts one rule row at status candidate so a scout-produced parameter set can reach keel rules backtest / keel simulate. Before this, rules seed built rows only from each kind’s constructor defaults and no CLI accepted params — a proposal had no path to evidence without hand-writing Python against Repository.insert_rule.

Status is a literal at the single insert — no flag, no override. A row this command creates must still clear rules backtest and rules promote before it can reach paper or live. That is the property that makes a write path into the rules table safe to add at all; an independent review attacked it specifically (env vars, --params '{"status":"live"}', an UPDATE path) and could not break it, and mutating the literal kills three tests.

#183 closes the validation gap #182 shipped with. Container params ({"oversold": [1,2]}) and typo’d Literal choices (entry_zone: "banana") were accepted, stored and rebuilt cleanly, then either crashed the backtest the row exists for or — worse — silently selected a different code path. entry_zone: "banana" falls through to ema_band: 7 trades against ema_touch’s 11, expectancy identical to ema_band to the last digit. Since rules promote re-runs the backtest against that same stored row, an operator could promote a rule believing it was ema_touch while every number they ever saw measured ema_band.

Deliberately not included

Economic range checks (a negative atr_stop_mult, rsi_period: 0) are still accepted. Those belong in the rule constructors where every caller benefits — Dca already validates its own fields this way. A CLI-side copy would be the second, drifting validation table the one-boundary design exists to prevent. Follow-up for TurtleBreakout / RsiMeanReversion.

uv.lock relocked in the same commit, per 0.5.0 through 0.5.4. Verified with uv sync --frozen, which accepted the lock and rewrote nothing beyond the five pyproject.toml bumps.

Gate: 2134 tests pass, ruff clean.

v0.5.4

Voir cette version sur GitHub ↗

Built from 5da34afd7935. Version binds to this hash: keel --version reports keel 0.5.4+5da34afd7935 [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.5.4-py3-none-any.whl
keel --version

⚠️ 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

fix(admission): pick the shortlist by name, not by whichever json is newest (#180)

Found by real use, minutes after 0.5.3 deployed.

What happened

The propose overlay globbed *.json under the proposals directory and took the newest by mtime. But that directory is shared: a scout run writes three files into it — <date>-shortlist.json, <date>-param-proposals.json, and <date>-research-briefs.md. The overlay selected whichever was written last, which was the param-proposals file, one minute newer than the real shortlist. Pressing p produced:

the shortlist file is malformed
  .../2026-08-07-param-proposals.json: proposal must be an object with a 'candidates' list.

Nothing was wrong with either file. The fail-soft path worked correctly — it explained itself instead of crashing — it was just reading the wrong file, and blaming the operator’s own strategy output for it.

The fix

Selection now matches on the filename convention (*shortlist.json), so a sibling written seconds later can never shadow the real shortlist.

Deliberately not “prefer a shortlist, else fall back to any *.json.” On a run that produced param proposals and no candidates, that fallback re-selects the sibling and reproduces the same confusing error in a rarer, harder-to-diagnose case. Strict matching means an absent shortlist reads as absent, which is the truth.

The pattern is *shortlist.json, not *-shortlist.json. I wrote the hyphenated form first and it rejected plain shortlist.json — the most natural name an operator would reach for, and the one 12 existing tests already used. Requiring the prefix separator bought nothing and broke the obvious case.

The not-found message now names the pattern, since selection-by-convention is undiscoverable from “no shortlist yet”.

filenameselected?
shortlist.jsonyes
2026-08-07-shortlist.jsonyes
2026-08-07-param-proposals.jsonno
2026-08-07-research-briefs.mdno
candidates.jsonno — message says what to rename it to
Verification

Test-first; both new tests failed for the right reason before the fix. One pins that a newer sibling loses to a correctly-named shortlist; the other pins that a directory holding only param proposals reads as “no shortlist” rather than “malformed shortlist”. 2085 tests pass, ruff and mypy clean. Verified against the real directory: it now selects 2026-08-07-shortlist.json.

chore: bump to 0.5.4 to unbreak the TUI propose overlay (#181)

Version bump only — no code changes. Ships #180.

Patch: no schema change, no rail change, nothing that alters what the agent trades or when.

Why this is worth cutting on its own

0.5.3 shipped the propose overlay selecting the newest *.json in the proposals directory. That directory is shared — a scout run writes a shortlist plus sibling param-proposal and research-brief files — so the overlay picked whichever was written last and told the operator their own strategy output was a malformed shortlist.

The overlay is unusable on the deployment until this ships, and it is the surface the entire admission workflow in #176 was added for. Batching it behind other work would leave the feature broken on the box for no reason.

uv.lock relocked in the same commit, per 0.5.0 through 0.5.3. Verified with uv sync --frozen, which accepted the lock and rewrote nothing beyond the five pyproject.toml bumps.

Gate: 2085 tests pass, ruff clean.

v0.5.3

Voir cette version sur GitHub ↗

Built from a2a1ea64e17b. Version binds to this hash: keel --version reports keel 0.5.3+a2a1ea64e17b [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.5.3-py3-none-any.whl
keel --version

⚠️ 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(tui): reach the asset-admission gate from the dashboard (screen / propose / discover) (#176)

The allowlist-admission workflow — the halal asset gate — was CLI-only. This puts it on the operator dashboard: three new overlays, s screen, p propose, d discover, alongside the existing i insights.

Nothing here admits, attests, or trades. keel assets attest stays deliberately CLI-only: it is the one step in this gate that rests on human judgment rather than code, and it must not be a keystroke.

The overlays
  • s screen — the current allowlist’s admission verdicts, with the reason for each. OFFLINE, DB reads only.
  • p propose — screens the newest *.json shortlist in config.proposals_dir (new setting, default ~/keel/proposals, ~ expanded at use rather than baked into a Config). OFFLINE. A missing directory, absent shortlist, unreadable file, bad JSON or malformed proposal each render as a calm, actionable explanation — never an error that takes the dashboard down. Invalid candidate entries are reported as INVALID, never dropped.
  • d discover — proposes NEW candidates from the venue’s own product list.

Each reuses keel/proposer.py and the existing _screen_product gate verbatim rather than growing a second rendering or admission path.

d is gated harder than f is

Discover is the second deliberate network exception in a dashboard whose contract is read-only and offline. Pressing d makes no call at all — it opens ARMED, rendering an explanation of what running it will do and that it is a live venue call. Only Enter, pressed inside the overlay, makes the one list_products request. The result is then HELD: later polls repaint it without re-fetching, and closing discards it so reopening is armed again. It is bounded by a timeout, because the operator is blocked on it with the screen frozen — a hung venue becomes a retryable discover failed: line, not a dashboard whose only exit is Ctrl-C.

Two tests pin this by driving the real loop: zero list_products calls across d + several polls + close, and exactly one per Enter with no re-fetch on subsequent polls.

The correctness fix this uncovered

DATA_DERIVED_FAILURES claimed to hold every failure class “DOWNSTREAM of having no cached history” but omitted history itself. So a product with zero cached candles was reported as history: 0 daily bars, need 1460 — which reads as “this asset is too young” when the truth is “we have never fetched it”. A candidate never fetched was indistinguishable, by its failure list alone, from one that genuinely is too young. Since screening an unfetched asset is the normal first step of admitting one, this was the common case, not the edge case.

The split and its explanation now live once, in screen.py beside the tag set that decides them:

split_failures(facts, result) -> (about_the_asset, about_our_cache)
missing_history_lines(product_id, not_assessable) -> list[str]

assets holdings --screen, assets propose, assets screen and the new TUI overlay all route through them, replacing three independent copies of the same logic. A zero-bar product now says “no local history — run keel fetch --products X first” and names what is unassessable until then, instead of asserting something false about the asset.

The suppression fires at exactly zero bars, so an asset with some history that still falls short is still reported as genuinely too young — pinned by counterpart tests on every surface, so the fix cannot decay into a blanket silencer.

keel assets screen was initially left out of that migration and a review caught it. It is the sibling of the new s overlay — both screen the same product set through the same gate — so leaving only one able to explain an empty cache would have handed an operator two different stories about the same allowlist depending on which surface they looked at.

Also
  • tui_cmd’s docstring claimed the TUI “never touches the network except when explicitly asked (f)”, while run_live has all along made an automatic ~30s get_accounts balance read — which run_live’s own docstring acknowledged. The two contradicted each other before this branch. The docstring now enumerates all three network touches honestly.
  • run_live’s help and insights branches each hand-rolled an identical scroll chain. Rather than copy it three more times, both now share one pure _scroll_offset, behaviour unchanged.
Verification

ruff clean, mypy clean, 2062 tests passing (1976 on main, +86).

Beyond running the suite, the two constraints that carry the safety weight were mutation-tested: removing history from the tag set fails 5 tests, and making discover fetch on open or on poll fails 4. Both were confirmed to fail for the right reason before the fix existed.

fix(admission): close the non-UTF-8 shortlist hole and pin the claims #176 made (#177)

An independent adversarial review of #176 returned SHIP WITH CHANGES: network gating and the read-only guarantees held up (34 socket-disabled checks, DB bytes identical, attest absent), with ten findings to fix. This is all ten, TDD — every test below was seen failing for the right reason before the fix, and every coverage-only test was verified to die under a deliberate mutation.

The two real bugs

F1 — build_propose_view broke its own “Never raises” contract. source.read_text() was guarded by except OSError, but UnicodeDecodeError subclasses ValueError (issubclass(UnicodeDecodeError, OSError) is False). A UTF-16LE+BOM shortlist — valid JSON, and what a scout run on Windows writes — escaped both handlers and propagated out of a function whose docstring promises “Never raises. Every failure mode is FAIL-SOFT”. Observed: the propose overlay repainting propose read failed: 'utf-8' codec can't decode byte 0xff… every poll forever, naming no file and no next step, surviving only on run_live’s broad except Exception; and keel assets propose --from exiting on a raw traceback on the same input. Both paths now take the existing unreadable fail-soft branch, which names the file.

The two comments this falsified — _do_propose_view’s docstring and the identical inline comment in run_live’s propose branch, both claiming an exception “can only come from open_state()… never from the shortlist read itself” — are corrected. Note they were not merely stale: build_propose_view screens every parsed candidate after parsing, so a locked DB surfaces there too. The handler is load-bearing for both, and now says which.

F9 — assets propose --json told a different story from every human surface. It emitted sc.result.failures raw, so at zero cached bars the payload carried history: 0 daily bars < 1460 required unflagged, while render_proposal_report, assets holdings --screen, assets screen and the TUI overlays all suppress that exact line and print the MISSING-DATA explanation. That is precisely the drift split_failures was created to end, surviving in the one surface a script reads and cannot argue with. --json now applies the same split and makes it explicit: failures (verdicts about the asset), not_assessable (suppressed, never dropped), missing_history.

F2 — the single-admission-path property, tested at the wiring point

Design constraint 4 (“every candidate routes through _screen_product, so nothing drifts onto a laxer gate”) was convention-only exactly where it is wired. Replacing _screen_product with an always-ADMIT stub in _do_screen_report or _do_propose_view left all 2062 tests green — the existing overlay tests only assert a title paints and Esc closes, never a verdict.

Two run_live tests now seed an asset with ample cached history and liquidity but no attestation, so the only thing that can reject it is screen_asset failing closed on attestation=None — something only the real gate does. Both assert the overlay paints REJECT plus attestation: MISSING, and assert the absence of ✗ history/✗ liquidity so the REJECT provably comes from the shariah criterion rather than an incidental data shortfall any stub would also produce.

Both mutations verified dead. The _do_screen_report one was also run against the full suite pre-fix: it failed nothing.

Honesty fixes — comments asserting more than the code can know
  • F3 split_failures claimed a shallow-but-non-empty cache means the asset “really is too young”. MarketFacts carries a bar count and no first-bar timestamp, so it cannot distinguish that from keel fetch --years 2, an aborted fetch, or a venue not serving the full window (fetch prints a note about exactly this). Reworded to say plainly that the two are indistinguishable here, why the gate still fails closed on the ambiguity, and what the operator should check first (keel fetch --products <id> --years 5, then re-screen). Not re-engineered.
  • F5 missing_history_lines said a zero-bar asset “is not too young, we have simply never fetched candles for it” — the same overclaim in the opposite direction, since at zero bars a three-day-old listing and a three-year-old one are literally the same input. It now refuses to rule either way.
  • F10 “the SECOND deliberate network exception” appeared 3× — module docstring, help Safety notes, and the operator-facing ARMED overlay — forgetting the ~30s live-balance refresh that has been a real get_accounts call since v3. It is the THIRD of exactly three, as tui_cmd’s docstring already said correctly. All three corrected and pinned by a parametrized test across all four surfaces. The help’s “Live balance” section never said the refresh is a live venue call at all — the omission that made “second” read as plausible three sections later — so it now says so, and says it is a read that places no orders.
  • F4 run_live said “Seven modes”; there are six.
Coverage for correct-but-untested code
  • F7 config.proposals_dir resolution was never executed by the suite (every test passed directory=), so hardcoding a path left 2062 tests green — and the default is ~/keel/proposals, inside the live deployment root. Two tests with HOME redirected at tmp_path: one pins that a non-default proposals_dir is honoured, one that the default resolves to <home>/keel/proposals expanded. Both die under a hardcoded path; both die under a dropped .expanduser().
  • F8 _DISCOVER_TIMEOUT_SEC had no test. Pinned per-call, not globally, so collapsing it into _BALANCE_TIMEOUT_SEC is caught too. Dies under both mutations.
  • F5 (test half) the semantic sentence, plus the guarantee that these lines never restate a suppressed failure verbatim (the call-site proxy "✗ history" not in text would miss a leak, since a leak carries no ✗).
F6 — a test renamed to what it actually tests

test_run_live_discover_closing_discards_the_held_result is now test_run_live_discover_reopening_after_a_run_is_armed_not_stale.

I took the rename rather than the “pin the close branch specifically” option, and verified the reason: deleting the close-branch clear alone leaves the test green; deleting both clears kills it. Nothing observable from outside run_live can separate them, because mode only ever becomes discover via the normal-mode d branch that also clears — there is no route into the overlay that bypasses it. Keeping both clears is deliberate defence in depth, so the honest fix is the name plus a docstring that states the disjunction and names both lines.

One thing I’d flag back

F5 was written as a coverage finding, but the sentence it points at is the same overclaim F3 flags, one step further along: “it is not too young” asserts a fact about the asset at exactly the bar count where we have zero evidence about the asset. I reworded it rather than only pinning it. Called out here in case the intent was to pin the existing wording as-is.

Gates

ruff check keel tests packages clean · mypy keel packages clean (95 files) · pytest -q 2080 passed (2062 baseline + 18 new).

feat(tui): show the patch segment in the dashboard header (#178)

The TUI header now renders keel v0.5.2 instead of keel v0.5.

Why

_short_version truncated to v<major>.<minor>, so 0.5.0, 0.5.1 and 0.5.2 all rendered identically as v0.5. That makes the header unable to answer the one question an operator glances at it for — is this box running the build I just shipped, or the one before it? Three releases went out in the 0.5 line today alone.

The detail that matters

Build metadata rides on the patch segment. A released version is 0.5.2+79f35b9e73d5, so parts[2] is 2+79f35b9e73d5 and a naive .isdigit() check returns False. Taking it verbatim would print a commit hash into a header line budgeted for a version; rejecting it would drop the patch on exactly the shape a release build emits — the common case, not an edge case. The patch is split on + and the numeric head kept.

Two further degradations, each keeping the most it can still vouch for rather than falling all the way back to v?:

InputRendersWhy
0.5.2v0.5.2the point of the change
0.5.2+79f35b9e73d5v0.5.2build metadata stripped — what a release actually looks like
2.0v2.0no patch segment; show what exists rather than inventing a .0
0.5.2rc1v0.5non-numeric patch; keep the two segments that did parse
unknown / 1 / “v?reserved for an unparseable major/minor
Verification

Test-first: the five patch-expecting cases failed before the change and pass after. 2083 tests pass (+3 new parametrize cases), ruff clean, mypy clean across 95 files.

Not visible on the live deployment until a release — it is pinned to 0.5.2+79f35b9e73d5, which predates this.

chore: bump to 0.5.3 for the asset-admission work (#179)

Version bump only — no code changes. Ships #176, #177 and #178.

Patch: no schema change, no rail change, nothing that alters what the agent trades or when.

Why cut this now rather than wait

#176 fixed a reporting bug that is live on the deployment right now. history was missing from DATA_DERIVED_FAILURES — the tag set marking a failure as downstream of an empty cache — so keel assets propose and keel assets holdings --screen printed:

This is a MISSING-DATA verdict, not a verdict about the asset.
  ✗ history: 0 daily bars < 1460 required

An asset nobody had ever fetched was reported as too young to trade. The fix is at the tag set, and three independently drifting copies of the split were consolidated into one.

Also included
  • The TUI can reach the admission gate — screen / propose / discover in the menu. propose and screen are fully offline; discover is network-gated behind an explicit keypress like f. None can attest, admit, or trade — attest stays CLI-only, since it is the human judgment the gate rests on. An independent review verified this with sockets hard-disabled: zero network calls on open, poll or refresh, and the DB byte-identical across every path.
  • #177 — UnicodeDecodeError subclasses ValueError, not OSError, so a non-UTF-8 shortlist escaped except OSError and broke build_propose_view’s documented never-raises contract, wedging the propose overlay on every repaint. Plus tests pinning that both TUI overlays route through _screen_product, which mutation-testing showed was convention-only.
  • #178 — the header shows the patch segment, so 0.5.1 and 0.5.3 no longer render identically as v0.5.

uv.lock relocked in the same commit, per 0.5.0 through 0.5.2 — leaving it behind is what made 0.4.0 fail its self-identification check. Verified with uv sync --frozen, which accepted the lock and rewrote nothing beyond the five pyproject.toml bumps.

Gate: 2083 tests pass, ruff clean.

v0.5.2

Voir cette version sur GitHub ↗

Built from 79f35b9e73d5. Version binds to this hash: keel --version reports keel 0.5.2+79f35b9e73d5 [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.5.2-py3-none-any.whl
keel --version

⚠️ 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

docs: correct the run script and plist claiming a headless cycle places nothing (#171)

Comments only. Both keel-live-run.sh and com.keel.live.plist documented a safeguard that is not in force on the live deployment.

What was wrong

Both files assert the scheduled cycle cannot place an order:

keel-live-run.sh: “Because confirm mode FAILS CLOSED with no TTY … this NEVER places an order and moves no money” com.keel.live.plist: “The detector still places nothing: confirm mode fails closed with no TTY”

That holds only with autonomy OFF. The live deployment has been running with it ON.

agent._effective_mode returns "autonomous" when config.auto_trade.mode == "confirm" and the profile row is autonomous — read fresh from the database every cycle, so it is not a config or deploy-time property at all. In that mode the confirm gate is skipped and the order is placed headlessly. keel-live.log has been recording agent.mode_resolved mode=autonomous on every cycle.

This is the kind of comment that costs something: it is exactly what an operator (or an agent reading the repo) consults to answer “can this thing trade without me?”, and the answer it gave was wrong.

What the comments say now
  • Autonomy is named as the thing that decides, with both branches spelled out.
  • Where to read the setting actually in force: tui --once prints the autonomy line, and each cycle logs agent.mode_resolved.
  • keel autonomy off applies on the next cycle; keel kill is what stops trading immediately.
  • guards.check runs first and is un-overridable under both settings — autonomy changes who is asked, never what is allowed.
  • The old wording is called out explicitly, so it is not restored by someone who remembers the file rather than the profile row.
Verification
  • 0 non-comment lines changed (checked mechanically against the diff).
  • plutil -lint passes; the plist still parses to the same twelve :05 triggers.
  • bash -n keel-live-run.sh passes.
  • 1902 tests pass, ruff clean.
  • paperforward-run.sh’s “places NOTHING real” is deliberately left alone — it is accurate, since paper routes upstream and never reaches an executor mode.

Anchor the live detector’s schedule to UTC (~13h of lag removed) (#172)

What changed

The supervised-live detector now runs on the UTC clock. It evaluates the freshly-closed daily bar at 01:20 UTC instead of 09:05 America/New_York, cutting the delay between a daily candle closing and the agent acting on it from ~13 hours to ~1h20m.

No Python behaviour changed. This is the schedule (keel-live-run.sh, com.keel.live.plist), plus tests that pin what the schedule is actually for.

Why 01:20, and not midnight

Daily candles close at 00:00 UTC, but the agent cannot see the closed bar at that moment. turtle_breakout._completed_days withholds the newest daily bar until the 00:00–01:00 UTC hourly bar has closed — a guard that exists to stop the account simulator consuming a still-forming day as if it were complete. So 01:00 UTC is the earliest instant a cycle sees fresh data, and SCHED_HOUR=1 is exactly that boundary.

The extra 20 minutes is margin for Coinbase to publish that hourly candle and for data.market_feed to persist it. A trigger at exactly 01:00 UTC would race the very bar it exists to consume.

The day-stamp is a correctness mechanism, not notification hygiene

The runner’s day-stamp file is the only thing preventing the live money path from entering the same daily signal twice. Its previous comment described it as avoiding a duplicate notification, which was wrong and made it look optional. Nothing downstream dedupes an entry:

  • get_open_positions gates exits, reconciliation and status — never entry.
  • The signals table is written but never read back.
  • client_order_id is a fresh uuid4() on every call, so the exchange cannot dedupe either.
  • The rails in execution/guards.py are dollar caps and account state, not per-day entry counters — a second entry inside the caps passes all of them.

The paper path does gate (strategy/paper.py refuses a second entry while a product is open). The live path does not. Two cycles in one UTC day means two entries off one daily bar.

Corollary worth knowing: a manual keel agent run bypasses the script, and therefore the stamp, entirely.

The invariant

For any UTC date X, the newest visible daily bar is constant — it is X-1 — across the entire eligible window [01:00 UTC, 24:00 UTC). So whichever eligible trigger fires first on UTC date X evaluates bar X-1 and stamps X; every later trigger that UTC day is a no-op. Every daily bar is evaluated exactly once: no missed day, no double day.

That only holds because the stamp and the gate are now on the same clock. A local date straddles two UTC dates, so the old local-anchored gate could run twice within one UTC day on the catch-up path (machine powered off until late in the local day, so the first available trigger is already the next UTC date, and the following local morning runs again on that same UTC date). That is pinned as a regression test.

Why 24 triggers is not a cadence change

The plist goes from 12 triggers to 24 (hourly at :20). launchd re-runs a missed StartCalendarInterval on wake from sleep, but not when the trigger passed while the machine was powered off — which silently cost a full day on 2026-07-28. The only defence is another trigger later. The trigger count is catch-up breadth, not cadence; the stamp is what keeps 24 triggers to one cycle. Catch-up window: 12h → 23h.

Because these are local hours and the gate is on UTC, every UTC date gets a trigger in every UTC hour, so an eligible one always exists — including across both DST transitions.

Incidental fix: the plist was not well-formed XML

This repo separates asides with a double hyphen. XML forbids one inside a comment. plutil -lint reported OK because Apple’s CoreFoundation parser is lenient and launchd never complained, but plistlib/expat rejected the file outright — so any ordinary XML tooling could not read it. The comment is rewritten without one, and a test keeps it parseable.

DCA budget: rule, config and simulator now agree

The live executor sizes DCA from config.dca.budget_usd and ignores the rule row’s own budget_usd. The live config said 50, the rule row said 25 — so 50 was what actually moved, while the account simulator (which does read the rule’s value) modelled 25.

50 is the intended budget. deploy/live-rules.json now records 50, matching both the config and the Dca constructor default. The divergence is documented at the point where it bites, and the manifest test now asserts rule-vs-config agreement rather than pinning a literal — a hardcoded number there would just re-create the drift one deploy later.

Operator action still required. This does not change the live database. scripts/rule_manifest.py apply deliberately never rewrites an existing rule’s params; it reports drift and exits 1. The rule row must be updated by hand:

sqlite3 ~/keel/keel-live.db \
  "UPDATE rules SET params = json_set(params, '\$.budget_usd', '50')
   WHERE kind='dca' AND json_extract(params,'\$.product_id')='BTC-USD';"

Note the quoted '50'. Decimal params are stored as JSON strings; json_set(..., 50) writes a number and the manifest still reports drift. Verify with:

python scripts/rule_manifest.py apply --db keel-live.db --manifest deploy/live-rules.json
# -> in sync: 6 rule(s) match deploy/live-rules.json
Tests

New tests/test_schedule.py (20 tests) and two characterization tests in tests/test_agent.py:

  • test_effective_bar_does_not_advance_until_0100_utc — parametrised over 00:05, 00:59, 01:00, 01:20, 02:20 and 13:05 UTC; pins the 01:00 boundary the whole schedule is built on.
  • test_exactly_one_run_per_utc_day_over_a_full_year — pure simulation of the gate over 13 months of real triggers spanning both DST transitions. Exactly one run per UTC date, every run in [01:00, 24:00), no date skipped. Run against America/New_York plus a southern-hemisphere zone and a half-hour-offset zone, to show the property comes from the schedule’s structure rather than a lucky offset.
  • test_spring_forward_... / test_fall_back_... — the two days most likely to be wrong. Spring-forward loses a trigger (23 that day); fall-back repeats local 01:20 at two distinct UTC instants on the same UTC date (25 that day), which is precisely why the stamp must be UTC-keyed.
  • test_the_old_local_date_gate_could_double_run_within_one_utc_day — the regression the change exists to prevent.
  • test_two_cycles_in_one_utc_day_place_two_dca_orders and ..._reenter_the_same_turtle_breakout — these assert the duplicate, deliberately. They document the hazard the stamp guards. If a real live-side entry dedupe is ever added they should go red, and the right response is to rewrite them to assert a single entry, not delete them.
  • Shell-level tests run the real script in a sandbox (its deployment root rewritten, with an assertion that no reference to the live path survives) to check the stamp is written on a clean exit and not written on a nonzero exit.

The plist’s trigger list is parsed, never hardcoded, so the tests and the plist cannot drift.

Suite: 1902 → 1924 passing. ruff and mypy clean.

Not in scope

Build only — nothing is deployed. paperforward-run.sh is deliberately left local-anchored: the paper path already refuses a second entry while a product is open, so a duplicate cycle there is inert.

test(manifest): keep the rule-manifest test able to catch a keel init reseed (#173)

What this is now

This PR started life as the deploy/live-rules.json DCA budget_usd 25 -> 50 change, split out of #172. #172 was merged and carried that change in, so the value edit, the executor.py comment and the prose are all on main already. Rebased onto origin/main, what remains is the one thing #172 did not land: repairing the test it silently weakened.

The problem

#172 replaced this assertion in test_committed_manifest_is_valid:

assert dca[0]["params"]["budget_usd"] == "25", "DCA budget must not revert to the default 50"

with a check that the manifest’s budget equals config.dca.budget_usd. Checking agreement rather than a literal is the right instinct — a hardcoded number there just re-creates the drift it is meant to catch, one deploy later.

But it lost something. The original assertion guarded a specific, real failure: keel init on a fresh box seeds every rule at candidate from each rule kind’s constructor defaults, silently discarding any parameter an operator tuned by hand, on a box that otherwise looks correctly provisioned. Nothing errors.

50 is also Dca.__init__’s constructor default. And deploy/live-rules.json’s DCA params are now exactly the four constructor defaults:

parammanifestDca.__init__ default
cadence_days77
budget_usd“50”Decimal("50")
dip_bonus_pct“0”Decimal("0")
lookback_days9090

So “manifest agrees with config” is satisfied both by a correctly-provisioned deployment and by one that keel init quietly reseeded. The values cannot tell those two states apart any more.

Why I did not do what was asked literally

The instruction was to assert “the value is not merely the constructor default”. That is impossible to satisfy honestly — the operator’s intended budget is the constructor default. Any assertion of that form would either be false or would require moving Dca’s default away from 50 to suit a test, which changes library semantics for a test’s convenience. So the guard is restored a different way.

What the test asserts now
  1. Agreement — manifest budget_usd == config.live-sandbox.yaml’s dca.budget_usd, parsed from the file, not hardcoded. This is the value the executor actually spends.
  2. Not seed-shaped — the DCA rule’s status is live, and no rule in the manifest is candidate. This is the only discriminator assertion (1) leaves standing: keel init always seeds at candidate regardless of what the params say.
  3. The coincidence is pinned, not assumed — the manifest’s DCA params are asserted equal to Dca(product_id=...).describe()["params"]. If the operator ever moves the budget off the default, this is what fails, and its message explains that the failure is good news: assertion (1) has regained the ability to catch a reseed on its own, and (2) is no longer the last line of defence.
Verification

Each assertion was confirmed to fail for the right reason, by mutating the committed manifest and reverting:

  • DCA status -> candidate: AssertionError: the live DCA rule is not status=live -- if this is a candidate, keel init likely reseeded it from Dca's constructor defaults...
  • budget_usd -> "25": AssertionError: the live DCA rule's budget_usd must match config.dca.budget_usd... assert Decimal('25') == Decimal('50')
  • cadence_days -> 14: trips assertion (3) with the “reasoning behind assertion (2) no longer applies” message.

ruff, pytest and mypy clean.

Make the 01:20 UTC detector safe: gate entries on a confirmed bar, and stop losing the day-stamp (#174)

Follow-up to #172, from an adversarial review of it. #172 verified the clock-side invariant correctly — 9624 simulated firings, 402 runs on 402 distinct UTC dates, both DST transitions — but it traded a ~13-hour data-publication margin for 20 minutes, and nothing measured whether the data had actually arrived. This PR closes that, plus three ways the day-stamp could be lost.

Nothing is deployed. ~/keel still runs the old local-anchored script, so there is no live exposure today — but a release cut from main as it stands would create it.


Finding 1 (HIGH) — a late candle re-evaluates yesterday’s bar, placing a duplicate order

turtle_breakout._completed_days withholds the just-closed daily bar until the 00:00–01:00 UTC hourly bar closes. Verified against the real function, at 01:20 UTC on UTC date X:

feed stateeffective newest daily bar
both series currentX-1 — correct
ONE_HOUR one bar lateX-2 — regressed
ONE_DAY not yet storedX-2 — regressed
(at the old 13:05 UTC slot, even 12 bars of hourly lag)X-1 — harmless

X-2 is the bar yesterday’s 01:20 UTC cycle already evaluated. Nothing on the live path dedupes an entry — get_open_positions gates exits/reconcile/status but never entry, the signals table is never read back, client_order_id is a fresh uuid4, and the rails are dollar caps, not per-day counters. So that re-evaluation is a duplicate real-money order, not a delayed one.

Why nothing caught it: market_feed.poll_once writes nothing and raises nothing when the venue has no new bar; run_once set last_feed_ts = now_ts unconditionally, so rail 12 — which measures whether the poller ran, not whether data arrived — was blind; and market_feed.is_fresh is measured against the finest configured granularity (FIFTEEN_MINUTE live), never ONE_DAY. The cycle exited 0, the runner stamped the day, and there was no retry.

The fix

data/freshness.py already had expected_last_ts and the bars_behind arithmetic and zero references from agent.py, execution/ or strategy/. New entry_bar_ready() uses them: the gated series must be bars_behind == 0, and every finer configured series must have advanced past that bar’s close.

That second condition is _completed_days’s own condition stated generically, so the gate cannot read “ready” while _completed_days would still withhold the bar. It is also no stricter than it must be — it only asks that the finer series crossed the boundary, not that it is at its own newest bar, so an hourly series five bars late at 14:20 UTC still confirms fine. A bars_behind == 0 requirement on FIFTEEN_MINUTE would have left only 5 minutes of margin at the 01:20 trigger and blocked entries routinely.

Deliberately not reusing assess()/DEFAULT_TOLERANCE_BARS: that 2-bar tolerance exists so an operator-facing staleness alert does not fire on the normal forming-bar lag. A one-bar-late hourly series is exactly what duplicates an order, and the alert tolerance would wave it through.

Where the gate lives, and why not rail 12

In run_once, not as a rail. Two reasons:

  1. A rail cannot fail the cycle. Rails yield placed=False and a zero exit. The runner would still stamp the day, and the fresh bar would never be evaluated at all — trading a duplicate order for a silently missed day. The gate has to fail the cycle so the runner declines to stamp.
  2. A rail only runs when a signal fires. On the ~99% of days no signal fires, a stale bar would still be stamped as “done”, and the real bar would go unevaluated.

Fixing rail 12 instead would be actively dangerous. Its threshold is interval_sec * 3 and it answers “did the poller run”. Making last_feed_ts advance only on new data would, in a once-daily deployment, leave it ~24h stale every cycle and veto everything.

Semantics
  • Entries only. Exits still run for every rule, unfiltered — an open position’s rule-driven channel exit runs in-process (the protective stop rests at the broker, the channel exit does not), and holding a losing position an extra cycle is strictly worse than a delayed entry.
  • Gated on the granularity the rule trades on. _entry_gate_granularity falls back to the coarsest configured granularity, diverging from engine._trading_granularity’s finest-fallback on purpose: Dca declares no timeframe yet reads candles_by_tf[ONE_DAY] directly and keys its cadence off that bar’s timestamp, so gating it on FIFTEEN_MINUTE would miss the same hazard entirely.
  • Nonzero exit. Single-cycle keel agent exits DATA_NOT_READY_EXIT (4), so the runner declines to stamp and one of the remaining 23 triggers retries an hour later — duplicate order → ≤60 minutes of delay. --loop is unchanged: it skips the cycle and tries again next interval.
  • Structured log. agent.entry_bar_not_ready names the granularity, expected ts, stored ts, bars_behind, reason and blocking series.

Findings 2/3/4 — three ways the day-stamp could be lost

Finding 2 (HIGH) — a failed stamp write was swallowed. Reproduced: with a read-only logs dir the cycle ran, the stamp was absent, rc was 0, and the next trigger ran a second full cycle. Two layers now:

  • Pre-flight, before keel is invoked at all — probe that the stamp is persistable, and refuse to run a cycle if not. This is the layer that matters. Exiting nonzero after a cycle has run does not prevent the duplicate: the order is already placed. The only way to turn “duplicate real order” into “no trading plus a loud alert” is to refuse to trade when we cannot record that we traded.
  • Atomic write + read-back afterwards, as belt and braces. > "$STAMP" truncates first, so a torn write left an empty stamp — which reads as “never ran” and re-runs the day.

Finding 3 (MED) — empty date -u output disabled the detector permanently. On this hardware $((10#$(date -u '+%H'))) on empty input evaluates to 0, not an error, so an empty clock gave TODAY="", HOUR=0; a missing stamp also reads "", so [ "" = "" ] was true — “already ran”, forever, with no alert. The unmodified script literally printed detector already ran this UTC day () -- skipping. Now validated before use (exit 64, nothing run, stamp untouched).

Finding 4 (MED) — the stamp compare was =, so a clock rollback re-evaluated a bar. A Mac booting with a bad RTC before NTP settles (RunAtLoad fires immediately) reads a past date, which != treats as “not today”. Now strictly-less-than, making the stamp monotonic. A malformed stamp is refused (exit 65) rather than compared — "garbage" < "2026-08-06" is false and would read as “already ran” forever.

Also: the “N signal(s) PENDING — run the agent interactively” notification now fires only on a clean cycle. Per the script’s own COROLLARY, running the agent by hand bypasses the stamp, so prompting for it off a failed cycle’s partially-parsed output pointed the operator straight at a duplicate entry. An ordinary nonzero keel exit deliberately does not notify — it is expected and self-healing, and alerting 23 times a day would train the operator to ignore the alerts that do need a human. The policy is written into the header, along with exit codes 64/65/66.

Finding 7 — “no missed day” was stated flatly in the header and plist; it is conditional on the machine being powered on for at least one eligible trigger that UTC day. Both now say so.

Finding 10 — the PENDLOG line logged local, unlabelled time next to lines that are all UTC.

Finding 8 (flock) — documented, not fixed. flock is not installed on macOS (verified), launchd will not start a job already running, and the race that actually matters is a manual run, which bypasses the stamp regardless of any lock.


Finding 6 — the tests were testing a model, not the artifact

_run_gate re-implemented the shell gate in Python, so the year-long simulation proved nothing about the shipped script. Worse, _skip_before_sched_hour made the only two real-shell tests silently skip whenever CI ran between 00:00 and 01:00 UTC — on the sole barrier to duplicate orders.

  • The skip is gone. Every real-script test now shims date on PATH and injects its own instant.
  • The model is pinned to the artifact. Driving the real script over all 9624 triggers would add ~2.5 minutes to a 10-second suite, so the model is kept — but test_the_simulated_gate_matches_the_real_script replays the real script over a curated adversarial sequence (normal days, the sub-SCHED_HOUR trigger, both DST transitions including the twice-fired local 01:20, and the boot-after-outage catch-up) and requires it to agree with the model at every trigger.
  • _stored_series no longer hardcodes instantaneous, never-failing publication — the assumption that made this whole bug class invisible. It takes hourly_lag_bars/daily_lag_bars, and a new test pins the premise against the real _completed_days.
Verification

Every new test was confirmed to fail for the right reason before the fix.

  • Neutering only the entry gate (engine.evaluate(ready_rules, …) → product_rules) makes all 5 new agent/CLI tests fail, with the turtle rule emitting a real ENTER Signal on ts=777600 — bar X-2, the already-traded bar.
  • Running the new schedule suite against the unmodified script: 10 failed, 21 passed, including rc=0 despite a “Permission denied” stamp write, and detector already ran this UTC day () -- skipping on an empty clock.

Independently re-verified end-to-end in a sandboxed copy of the script (live path asserted absent):

scenariocycles runrcnotified
empty date -u064yes
read-only logs dir0 (was 2, rc=0)66yes
happy path10—
second trigger, same UTC day10—
clock rolled back 200 days10—
clock corrected10—
next UTC day20—
corrupt stamp065yes
failed cycle printing signals=217no PENDING prompt, no stamp

Stamp never truncated: forced write failure left yesterday’s 2026-06-14 intact.

Gate: ruff clean · mypy clean (94 files) · 1953 passed (from 1924 on main).

Suite time went 10.3s → 19.5s. ~2.4s of that is the differential test; the rest is the 11 per-property real-script tests. That is the honest price of Finding 6 — the alternative is deleting artifact-level coverage of the only barrier against duplicate real-money orders.

What this does not do

It is not an entry dedupe. The day-stamp remains the only thing preventing two cycles in one UTC day from entering twice, and the two characterization tests in tests/test_agent.py still pin that hazard (they are unaffected — their config declares only ONE_DAY, so there is no finer series to confirm against). This PR removes the specific way a late candle could make the stamp’s “one run per UTC day” guarantee re-evaluate an already-traded bar.

fix(tests): unbreak CI on Linux, and bump to 0.5.2 (#175)

Two commits: a CI fix that main needs right now, and the 0.5.2 version bump that depends on it.

1. main is red — the real-script tests are macOS-only

#174 added tests that execute the shipped keel-live-run.sh under /usr/bin/sandbox-exec, so a test run can never fire a real notification on a machine that also trades real money. The harness also uses BSD date -r and chflags uchg. None of those exist on Linux, so all 45 invocations died with FileNotFoundError: /usr/bin/sandbox-exec on the ubuntu runner, and main’s push CI has been failing since #174 merged.

This is not a coverage question worth solving portably. The artifact under test is a macOS deployment — launchd plus osascript — and the script only ever runs on the box that owns the launchd job. So on non-Darwin the real-script tests skip, while the schedule invariant (exactly one cycle per UTC date, across both DST transitions) stays covered on every platform, because that is proven by the pure-Python model tests.

One runtime guard in _run_script covers all 45 call sites, since every invocation already funnels through it. The five tests that reach for chflags before running the script carry an explicit @_macos_only mark, where the runtime guard would come too late.

This blocks everything else: release.yml refuses to publish unless tests pass, so 0.5.2 could not be cut with main in this state.

2. Bump to 0.5.2

Patch — no schema change, no new capability. Ships #169, #172 and #174.

#174 is why this release exists rather than #172 alone. #172 retimed the live detector to 01:20 UTC, cutting ~12h of detection lag, but traded a 13-hour data-publication margin for 20 minutes — and nothing on the live entry path dedupes an order. #174’s freshness gate in run_once withholds all entries whenever any bar is unconfirmed, and it must ship in the same wheel as the schedule it protects.

That makes the deployment order strict: install this wheel first, then copy keel-live-run.sh / com.keel.live.plist. The shell files alone are the unsafe half. The plist also goes from 12 triggers to 24, so it needs a launchctl reload, unlike the comment-only change in #171.

uv.lock relocked in the same commit, verified with uv sync --frozen.

Gate: 1976 tests pass locally, ruff clean, mypy clean.

v0.5.1

Voir cette version sur GitHub ↗

Built from d082371226e4. Version binds to this hash: keel --version reports keel 0.5.1+d082371226e4 [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.5.1-py3-none-any.whl
keel --version

⚠️ 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

fix(telemetry): log an unreachable venue as a warning, not an ERROR traceback (#169)

An unreachable venue is now logged as a one-line warning instead of a full ERROR traceback, so grep ERROR in the live log means something again.

What was happening

A 35-minute offline window on 2026-08-06 — laptop asleep, DNS not up after wake — wrote 124 ERROR records with 20-frame tracebacks into keel-live.log. The TUI polls the live balance every 30s (_BALANCE_REFRESH_SEC), and every failed poll was logged twice: once by cb_client.get_accounts, then again by executor._fetch_available_quote, which re-logged the exception the client had already logged and re-raised.

Nothing was broken. The venue was unreachable, every caller already fails soft, and the condition cleared by itself. The cost was that a real 401 Unauthorized in the same log is one line among 124.

What changed

telemetry.log_venue_failure, for any call that crosses the network to a venue. It picks severity by what the failure actually cost:

conditionlevelbody
unreachable, no cycle bound (a dashboard balance refresh)WARNINGone line, unreachable=true + truncated error summary
unreachable, inside a trade cycleERRORno traceback — rail 13 fails closed on a missing balance, so an order did not go out
anything else (auth, malformed, a bug)ERRORfull traceback, byte-for-byte what log_exception emitted

Unreachability is matched on exception type name over the __cause__ chain, so keel-core needs no requests/urllib3 dependency to classify their exceptions, and the check holds for any broker adapter’s HTTP stack. SSLError is deliberately excluded — a failed handshake can mean interception, which an operator must see at ERROR.

Control flow is untouched. get_accounts still raises; _fetch_available_quote still returns None so rail 13 still fails closed. Only the severity of the record changes.

Verification
  • 1902 tests pass, ruff check keel tests packages clean, mypy clean across 94 files.
  • 25 new tests, written failing first.
  • Validated against a real requests failure rather than only the name-matched fakes: ConnectionError → MaxRetryError → NameResolutionError → gaierror, all four in the match set.
  • Replaying all 124 recorded failures from keel-live.log through the classifier leaves 2 at ERROR — exactly the 401.

chore: bump to 0.5.1 for the venue-unreachable logging fix (#170)

Version bump only — no code changes. Pairs with #169.

Patch, not minor. #169 changes log severity and nothing else: cb_client.get_accounts still raises, executor._fetch_available_quote still returns None, and rail 13 still fails closed on a missing balance. No behaviour an operator or a rail can observe has changed — only whether a record lands at ERROR with a 20-frame traceback or at WARNING on one line.

uv.lock is relocked in the same commit, per 0.5.0. Leaving it behind is what made the 0.4.0 release fail its self-identification check: CI’s uv sync rewrote a tracked file, so the artifact reported (DIRTY). Verified here with uv sync --frozen, which accepted the lock and rewrote nothing beyond the five pyproject.toml bumps.

The live deployment is on 0.5.0, whose TUI balance poll writes a full ERROR traceback every 30s for as long as the machine is offline. Cutting this release is what gets the fix there.

v0.5.0

Voir cette version sur GitHub ↗

Built from 251ceb11eeee. Version binds to this hash: keel --version reports keel 0.5.0+251ceb11eeee [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.5.0-py3-none-any.whl
keel --version

⚠️ 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(guards): add rail 19, the spot-instrument rail (R2) (#167)

Implements R2 from docs/experiments/2026-08-05-coinbase-asset-class-feasibility.md, closing the residual rail 18 (#164) deliberately left open.

The residual

Rail 18 checks the settlement leg. It does not check the instrument shape:

quote_currency_of("BTC-PERP-USD") == "USD"     -> rail 18 passes
_asset("BTC-PERP-USD")            == "BTC"     -> rail 1 passes (BTC is allowlisted)

Verified against the real live config — a derivative-shaped id with a legitimate final segment clears both shipped defences. Rail 19 is the only thing that stops it, and that is asserted as a test, in the same “the hole, stated as a test” style as rail 18’s.

rail 1rail 18rail 19
BTC-PERP-USD, SELL and BUY, live and offlinepassespassesVETO 4/4
the 6 live deployment rulespassespassespasses 4/4
A validator, not a classifier

A classifier (id → {spot, futures, equity}) must enumerate the shapes keel refuses, so it fails open on a shape Coinbase has not invented yet. A validator answers only “is this a well-formed spot id” and fails closed on novelty — the right posture for a spot-only halal agent, and it avoids building the id→class classifier the study prices as A1 scope.

Grammar: [A-Z0-9]{1,16}-[A-Z0-9]{2,10}. All 936 live spot ids match; all 99 futures (ROOT-DDMMMYY-CDE) and all 1000 equity ids (64-hex) fail. The quote bound is tied to config’s _CURRENCY_CODE_RE so the two grammars cannot drift apart. quote_currency_of is untouched — rails 13/18 depend on its loose parse to name "CDE" in their messages.

Two deliberate corrections

The history walk counts, it does not skip. The study said “skip-or-flag” an unparseable row in _open_exposure_by_asset. Skipping would reduce measured exposure and thereby loosen rails 4/5/6 — fail-open. It now logs a warning and still counts the row. Overcounting is the closed direction.

assets screen is exempt from --products validation. Every other caller (fetch, monitor, simulate, rules seed) now refuses an untradeable id at the keyboard. Screening must not: it is the command that answers “may keel trade this, and why not”, and screen_asset reports REJECT with the reason. A usage error would make the one tool whose job is to explain an inadmissible asset the one tool that cannot be asked about one. Pinned by a test.

What was deliberately NOT built

No engine read of capabilities().asset_classes. The live path builds CoinbaseClient, which has no capabilities(); paper passes broker=None. Such a gate is dead code that reads as a defence — the same pattern already built and deleted once in #164. The field instead gets a vocabulary check and conformance assertions so it cannot rot, and the reconciliation is deferred to the broker-port migration as a load-time check.

Also not built: the id→class classifier (A1 scope, fails open), and capabilities() on CoinbaseClient (that is the migration itself, or a second source of truth).

Verification

1804 tests pass (1698 → +106), ruff check . clean.

Separately, the deployment upgrade to 0.4.0 was verified end to end: a full agent cycle ran clean against a throwaway DB copy — mode=confirm polled=225 products=['ADA-USD','BTC-USD','ETH-USD','PAXG-USD','XLM-USD'] stale=[] signals=0.

chore: bump to 0.5.0 for the rail 19 release (#168)

Version bump only — no code change. The release workflow refuses to set the version itself, so this is the reviewed PR that decision belongs in (docs/RELEASING.md).

Why minor: rail 19 (#167) is a new un-overridable hard rail, adds a spot_instrument screen criterion, adds a load-time failure for a non-uppercase allowlist entry, and tightens --products at the CLI on every command that writes or trades.

Why now: ~/keel runs 0.4.0, which has rail 18 but not rail 19. The BTC-PERP-USD residual — an id that passes rails 1 and 18 and is stopped only by rail 19 — is still open on the live path until this ships.

uv.lock is relocked in the same commit. Leaving it behind is exactly what failed the 0.4.0 release: CI’s uv sync rewrote a tracked file, so the artifact self-reported (DIRTY).

Verified by replaying the full CI sequence on a clean clone — sync (tree stays clean) → stamp → uv build --all-packages → install the wheel by path:

keel 0.5.0+f4944aea64a8 [release]

No (DIRTY). 1888 tests pass, ruff clean.

After merge: Actions → Release → Run workflow with 0.5.0.

v0.4.0

Voir cette version sur GitHub ↗

Built from dc9960153fee. Version binds to this hash: keel --version reports keel 0.4.0+dc9960153fee [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.4.0-py3-none-any.whl
keel --version

⚠️ 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.

Docs, CI & tooling

Make the live deployment’s config and rule set reproducible (#161)

A rebuilt keel deployment would not come back the same. Two independent gaps, both silent.

The rule set is not in version control

A deployment’s strategy library lives in the database, not in any config file. An in-place upgrade preserves it — the schema steps are additive (CREATE TABLE IF NOT EXISTS rules) and keel migrate never seeds. A fresh deployment does not: keel init seeds every rule at candidate from each rule kind’s constructor defaults.

So the live DCA rule, deliberately sized to budget_usd: 25, comes back on a rebuilt box as the built-in 50, unpromoted — twice the money, on a machine that otherwise looks correctly provisioned. Nothing errors, and nothing in the repo records that 25 was ever the intent.

deploy/live-rules.json is now the committed snapshot of the live rule set. scripts/rule_manifest.py export regenerates it; apply rebuilds a database from it.

Two refusals, both deliberate:

  • It never rewrites an existing rule’s params. Matching is by (kind, product_id) — the same key keel rules seed uses — and a rule that exists with different params is reported as DRIFT with the run exiting non-zero. A manifest that could resize a live rule by file edit would route around exactly the review the promotion ladder exists to force.
  • It refuses to create live rules without --allow-live, warning loudly per rule when it does. Seeding straight to live is the same gate bypass as keel rules seed --status live and rules promote --force, so it reads like them.

Dry-run is the default; --apply writes. Promotion stays keel rules promote’s job.

Eight tests, including one that asserts the checked-in manifest still rebuilds and still says 25 — so a stale manifest fails CI rather than a future deployment.

The operator configs were git-ignored

config.live-sandbox.yaml, config.paperforward.yaml, both run scripts, both launchd plists and the keel-live/keel-paper wrappers were ignored (c564db7 and earlier) as “operational, never shipped”. Both halves are still true — but “never shipped” is enforced by the packaging config, which ships only keel/, not by .gitignore. Ignoring them bought no shipping guarantee and cost the one that matters: the only copy of the running deployment’s configuration lived in ~/keel on a single laptop. No history, no offsite copy. The nearest thing to a backup, ~/keel/.backup-20260728/, is already stale — it predates the 2026-07-29 switch to logging.verbose: true on the live side.

A fresh deployment cannot reconstruct this from the release either: the release asset config.yaml is keel/templates/config.live.yaml, a different file with a different allowlist and different caps than the sandbox actually in use.

They hold no credentials — those stay in .env, still ignored. Verified by grep across all eight before staging. The repo is private, so this exposes the caps and allowlist to nobody who cannot already read the code.

Also documented

Two things that each cost a failed command to discover, now in the go-live runbook:

  • A DCA rule can never clear the promotion gate. It has no stop and no target, so backtest() opens a position that never closes; every trade stays open and the aggregates see n_trades=0 against min_trades: 100. A DCA go-live is rules promote <id> --force, twice — structural, not a judgement call.
  • It needs --granularity ONE_DAY, because Dca never sets self.granularity the way TurtleBreakout does.
Verification
  • Rebuilt a fresh DB from the committed manifest and read it back through the agent’s own _build_rule: all six rules, correct statuses, budget_usd = Decimal('25').
  • Dry-run against the real keel-live.db: in sync: 6 rule(s).
  • Full suite 1678 passed; mypy clean on 197 files; ruff clean.

The manifest currently records the DCA rule as candidate, which is its actual state — the --force promotions are still pending.

Record the BTC DCA rule as live in the deployment manifest (#162)

One-line manifest change, regenerated from keel-live.db rather than hand-edited:

       "kind": "dca",
-      "status": "candidate",
+      "status": "live",

Rule 6 (dca, BTC-USD, budget_usd: 25, weekly cadence) was promoted candidate -> paper -> live on 2026-08-03.

Why it needed --force, twice

A DCA rule has no stop and no target. backtest() opens a position that never closes, so every trade stays open, the aggregates exclude it, and the promotion gate sees n_trades=0 against min_trades: 100. It cannot clear the floor — not with more history, not ever. That is the structural case promote --force documents, now also written down in the go-live runbook.

(It also needs --granularity ONE_DAY on the gated path, because Dca never sets self.granularity the way TurtleBreakout does. Moot under --force, which skips the backtest.)

Why this file matters

budget_usd: "25" is the entire point of the manifest. keel init on a rebuilt box seeds from constructor defaults and would bring the rule back at 50, unpromoted — twice the money, on a machine that otherwise looks correctly provisioned.

Context

This is keel’s first live rule that fires on a schedule rather than on a market setup. The five turtles have been live since go-live and have never signalled once: price has sat 3–27% below the 40-day Donchian entry on every product, closest being PAXG at 3.09%.

Rail 17 (withdrawal capability) was attested --enabled the same day, and that is what actually unblocked BUYs. It had been failing closed since go-live and would have vetoed the first turtle breakout too — the rails had simply never been exercised against a real signal. That attestation expires after 7 days and is deliberately not automatable, so it is account state, not repo state, and does not belong in this file.

First order fires on the next cadence boundary, Thu 2026-08-06, and only at a terminal — the launchd job runs headless in confirm mode and fails closed without a TTY.

Other changes

docs(research): can keel trade Coinbase’s new asset classes? (#163)

Question

Coinbase now lists futures, perp-style futures, equity/thematic indices, commodities and US equities alongside spot. Can keel trade all or some of them?

Answer

No — none of them today. Only the crypto-futures family is even a candidate for a future build.

The document records a live, read-only probe of GET /api/v3/brokerage/products and the market-data/preview endpoints, run against the deployment’s own CDP key on 2026-08-05, plus a file-by-file audit of what keel assumes.

ClassOn the API?Market data?Order path?Verdict
FX✗ none exist——Nothing to evaluate
US equities (venue CCM)✓ 1000 products✗ no candles, 500s on book/trades✗ preview refused by designBlocked by the venue — not buildable
Crypto futures + perp-style✓ 99, all EXPIRING✓ verified OHLCV, book, trades⚠ 403 pending CFM onboardingBuildable, ~35–65 eng-days, halal unresolved
Commodities / indices✓ same venue✓⚠ same 403As above, plus session handling; worst halal position

Three distinct blocker classes are separated deliberately, because they cost very different things: account paperwork (CFM onboarding), venue capability gaps (equities), and keel architecture gaps (13 areas — instrument identity, position/margin model, direction model, lot rounding, expiry/roll, 24-7 assumptions).

Incidental finding worth acting on

guards._asset("ADA-28AUG26-CDE") returns "ADA", which is allowlisted on the live config — so rail 1, the un-overridable halal gate, passes a futures contract today. Only rail 13 catches it, and rail 13 is BUY-only and skipped in paper mode. keel refuses these products by accident (it never constructs such an id), not by design. The doc recommends closing this regardless of whether anything else is ever built.

Scope

Documentation only. No code changed, no product admitted, allowlist unchanged. create_order was deliberately never exercised against equities — that would have placed a real order.

feat(guards): add rail 18, the settlement-currency hard rail (#164)

Implements R1 from docs/experiments/2026-08-05-coinbase-asset-class-feasibility.md.

The hole

The study found by execution that keel’s rails can be fooled by a non-spot product id:

  • guards._asset("ADA-28AUG26-CDE") returns "ADA" — which is allowlisted on the live config.
  • So rail 1, the un-overridable halal allowlist gate, passes a Coinbase futures contract.
  • The only thing that stopped a live BUY was rail 13, incidentally (quote_currency_of returns "CDE", no such balance exists, fails closed). But rail 13 is BUY-only and is skipped entirely in paper mode.
  • Verified: a SELL of ADA-28AUG26-CDE passed every rail on the real live config.
The fix

Rail 18 — settlement-currency. Vetoes any intent whose quote_currency_of(product_id) is not in config.settlement_currencies (new field, default {USD, USDC}).

It is the only rail that gates the instrument class rather than the trade, and it does so without keel needing an instrument model it doesn’t have: quote_currency_of returns "CDE" for every futures contract and None for every equity id (a 64-char hash), so one comparison rejects both classes.

Deliberately not in LIVE_STATE_RAILS — it runs on both sides in every mode, because it needs no broker and no account state. That is the entire point: paper-exemption is what left the hole open.

Verified against the real ~/keel/config.live-sandbox.yaml:

productSELL/offlineSELL/liveBUY/offlineBUY/live
ADA-28AUG26-CDEVETOVETOVETOVETO
GOL-25NOV26-CDEVETOVETOVETOVETO
equity hash idVETOVETOVETOVETO
ADA-USDpassespassespassespasses
Accepted behaviour change

With the default set this also rejects ~120 non-USD/USDC spot pairs Coinbase lists (BTC-EUR, ETH-GBP, *-USDT). Nothing in the live deployment reaches one — every rule is BASE-USD and all three configs set quote_currency: USD — and settlement_currencies is the escape hatch. Flagged in the rail’s comment so it doesn’t read as an oversight.

Note on the venue reconciliation

_reconcile_settlement_currencies checks the configured set against the adapter’s declared BrokerCapabilities.quote_currencies, but it is dormant on the live path today: the live broker is data.cb_client.CoinbaseClient, which declares no capabilities (the declaration lives on the not-yet-wired CoinbaseAdapter). It activates when the broker port migration lands. Rail 18 does not depend on it. This is stated in the docstring rather than left to be discovered.

Verification

1697 tests pass (1678 before + 19 new), ruff check . clean. TDD order held — new tests were run failing first.

chore: bump to 0.4.0 for the rail 18 release (#165)

Version bump only — no code change.

The release workflow deliberately refuses to set the version itself, so this is the reviewed PR that decision belongs in (docs/RELEASING.md).

Why minor, not patch: rail 18 (#164) is a new un-overridable hard rail, adds a settlement_currencies config field, introduces a load-time ConfigError for a config whose quote_currency falls outside that set, and changes admitted behaviour — non-USD/USDC spot pairs are now vetoed.

Why it matters now: ~/keel is still running 0.3.2. I verified directly against the deployment’s venv:

deployment has settlement_currencies field? False
deployment guards has rail 18?             False

So the futures/equity hole rail 18 closes is still open on the live path. Cutting 0.4.0 is what actually gets the rail there.

After merge: Actions → Release → Run workflow with 0.4.0, then install the wheels by path into ~/keel/.venv and keel migrate. No schema change in this release, and the three deployment configs need no edits — all set quote_currency: USD and pick up the new field’s default.

chore: relock at 0.4.0 so the release build is not dirty (#166)

Fixes the failed 0.4.0 release (run 31032617294).

What failed
keel 0.4.0+eae6c0437fdd (DIRTY) [release]
::error::artifact reports a dirty tree

Lint, tests, stamp and build all passed. Only the self-identification check failed.

Why

#165 bumped all five workspace members to 0.4.0 but left uv.lock pinning 0.3.2 — my miss. CI’s Sync dependencies step relocks to match, which modifies a tracked file. By the time Verify the artifact identifies itself runs keel --version inside the checkout, git status --porcelain is non-empty.

version.build_info() deliberately consults git even when a stamp is present:

⚠️ A STALE stamp in a working checkout would otherwise claim [release] and hide a dirty tree — which is precisely the misreport this module exists to prevent. If git is present and disagrees with the stamp, believe git.

So this was the guard working correctly. The stamp said DIRTY = False honestly; the tree genuinely was dirty. Worth noting keel/_build_info.py and dist/ are both gitignored, so neither the stamp nor the build output can trip this — the lock was the only tracked file involved.

Fix

uv lock, committed. Reproduced locally first: running it on main produced exactly M uv.lock, and it moves all five members 0.3.2 → 0.4.0. No 0.3.2 remains in the lock.

No tag was created (the workflow tags after verification), so re-running Release with 0.4.0 after this merges is clean — no tag to delete.

1698 tests pass.

v0.3.2

Voir cette version sur GitHub ↗

Built from 712c976b3f33. Version binds to this hash: keel --version reports keel 0.3.2+712c976b3f33 [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.3.2-py3-none-any.whl
keel --version

⚠️ 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.

Features

feat(engine): log why a rule declined, with the near-miss numbers (#158)

A cycle reporting signals=0 said nothing about why. Whether price was 1% or 40% away from the trigger is the difference between “nearly fired” and “nowhere near”, and answering it meant replaying the rules against the database by hand.

Now the log answers it:

{"event": "engine.no_signal", "rule": "turtle_breakout", "product": "ETH-USD",
 "gate": "donchian_high", "close": 1919.9, "entry_level": 1979.0, "gap_pct": 3.08}
How

Rule.last_rejection (additive, defaults None) records the gate a bar tripped and the numbers behind it. engine.evaluate folds it into the engine.no_signal event it already emits per declining rule, along with the rule’s product.

No new event and no new call site — that is the constraint that shaped this. strategy.backtest and sim.portfolio_sim drive detect() once per bar, so logging from inside the rule would emit millions of events in a sim. The rule only ever writes an attribute; the engine, which already logs exactly once per declining rule, does the logging.

product comes from the rule rather than a Setup (there isn’t one) and is omitted entirely when a rule declares none — every turtle rule is named turtle_breakout, so without it the five lines a five-product cycle emits are indistinguishable.

What TurtleBreakout records

Every decline path: insufficient_history, donchian_high, adx, macd_histogram, min_volume, atr, stop_not_below_entry, s1_filter. From donchian_high down, each carries close / entry_level / gap_pct — so the interesting case, price clearing the channel and something else declining it, is visible rather than inferred. That is exactly ETH-USD on 2026-07-28: it closed above its 40-day high and only the ADX filter (21.3 < 25) declined it, which signals=0 gave no hint of.

A bar that fires clears the field, so a stale reason can never misreport a firing rule.

Scope

Purely diagnostic — no gate, guard or sizing path reads last_rejection, and a rule that records nothing logs the bare event it logged before (pinned by a test). Only turtle_breakout records reasons so far; the other three rules are unaffected and log exactly as before.

Verified against the live paper database: the emitted numbers reproduce the by-hand analysis exactly (BTC 4.82% below channel, ETH 3.08%, PAXG 7.49%, XLM 44.68%, ADA 23.49%).

Fixes

fix(tui): toast on ‘r’ so refresh is distinguishable from a dead key (#159)

Pressing r in the TUI appeared to do nothing. It was working — it just never said so.

What was wrong

The handler set no message:

if ch == ord("r"):
    last_balance_ts = 0  # force the balance to re-fetch on the next iteration too
    continue

Three things stacked to make a working key look dead:

  1. No toast. a (autonomy) and f (fetch) both assign message; r didn’t.
  2. The repaint is byte-identical. r re-reads the same local DB, which only changes when an agent cycle runs — once a day on a daily-Turtle deployment.
  3. Its one visible effect is deferred. The balance re-read sits after _paint on purpose (so the first frame never blocks on a network call), so the new value appears on the next repaint, not the one r triggered.
The fix

r now toasts, and the wording also answers the question the silence provoked — what r is for compared to f:

refreshed local state -- balance updating (no candle fetch; use [f] for that)

Deliberate wording choices:

  • “balance updating”, not a claim it is already showing — it lands on the following frame.
  • “no candle fetch” — the real distinction. r pulls no market data at all; f backfills 5 years for every allowlisted product. The footer calling both “refresh”/“fetch” gave no hint.
  • 77 chars, so it fits an 80-column terminal.
  • Styles as ok — a routine success must not borrow the alert colour that _message_style reserves for failures and for arming autonomy ON.
Tests
  • r paints a toast, driven through run_live with the existing _KeySequenceStdscr + fake-curses harness, so it exercises the real key handler rather than a stand-in.
  • the toast styles as ok, guarding against a future rewording that trips _message_style’s “failed” / “cancelled” keywords.

No behaviour change beyond the message: r does exactly what it did before.

Docs, CI & tooling

docs(readme): how to deploy a release, and ignore the operator wrappers (#157)

Two small chores that had been sitting outside the repo.

Deploying a release into a deployment

docs/RELEASING.md covers cutting a release but stops at the published artifacts. How to get one into a deployment (~/keel) lived only in shell history, which is how a deployment ends up a version behind without anyone noticing. New Deploying a new version section in the README documents the four commands, plus the parts that are easy to get wrong:

  • run them from the deployment directory — every path (Release/, .venv) is relative to it
  • --find-links Release is what lets the single keel_trader wheel resolve its keel-core / keel-broker-* siblings, which is why step 1 downloads all the wheels
  • installing by path rather than by bare name is deliberate: keel on PyPI is an unrelated project, so pip install keel fetches a stranger’s code
  • keel --version is the check that matters — it must report [release] bound to a commit, and a (DIRTY) or [checkout] build corresponds to no commit and must not be run against live funds
  • a new build takes effect on the next scheduled cycle with nothing to restart, since each cycle is a fresh process — but a long-running keel tui keeps the build it started with

The four commands were run against the live deployment as written; keel --version returns exactly the line quoted in the README.

Ignore the operator wrappers

keel-live and keel-paper are deployment artifacts with config and database paths pinned together, copied to ~/keel rather than tracked here — the same treatment their sibling scripts already get. They were showing up as untracked in every git status.

v0.3.1

Voir cette version sur GitHub ↗

Built from deb8fa7e978d. Version binds to this hash: keel --version reports keel 0.3.1+deb8fa7e978d [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.3.1-py3-none-any.whl
keel --version

⚠️ 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

fix(turtle): drop the daily bar only when it is really forming (#155)

The live agent was deciding on a daily bar up to 48h old — a full day of lag on every Turtle breakout entry and channel exit.

TurtleBreakout.detect()/exit_signal() dropped the last ONE_DAY bar whenever an ONE_HOUR key was present. That is the right guard for the account simulator: sim.portfolio_sim iterates hourly and hands the rule a daily series whose last bar is the current, still-forming day, whose stored OHLC is the completed day — consuming it intraday is lookahead.

But the live agent (agent.run_once) passes ONE_HOUR too, and data.market_feed persists only CLOSED candles, so its newest daily bar has already closed. The presence of ONE_HOUR was standing in for “this is the sim”, and in the live path it silently threw away a completed day.

Observed on the paper-forward deployment on 2026-07-28: the newest stored daily bar was 2026-07-27, and the rule was deciding on 2026-07-26.

The fix

Decide by where the hourly series sits, not by whether it exists: the newest daily bar is still forming unless the newest hourly bar opens at or after that day’s close.

This leaves the sim’s behaviour provably unchanged. portfolio_sim slices its daily series with bisect_right(daily_ts, t) against the current hourly bar t, so the last daily bar always contains that hourly bar — the new condition is never true there. In the live agent it is true from the first full hour of the next UTC day.

Against the live paper database this moves the decision bar from 2026-07-26 to the newest closed day, 2026-07-27.

Tests

Three new tests in TestCompletedDailyBarIsUsedInTheLiveAgentPath, written first and watched fail:

  • a breakout on the newest closed daily bar fires (was None)
  • an exit on the newest closed daily bar fires (was False)
  • the account-sim shape still drops its forming bar — the regression guard for the sim

No behaviour change for the edge backtester, which passes no ONE_HOUR key at all.

v0.3.0

Voir cette version sur GitHub ↗

Built from 0a1e1fca6aef. Version binds to this hash: keel --version reports keel 0.3.0+0a1e1fca6aef [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.3.0-py3-none-any.whl
keel --version

⚠️ 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.

Features

feat(insights): read-only insights + journaling reporting surface (#149)

What

Adds a read-only insights/journaling reporting surface — a pure view over the existing keel.db, in the same spirit as keel tui. Turns the slow, months-long paper-forward evidence accrual into something legible.

Two subcommands under a new keel insights group:

  • keel insights summary [--json] [--rule KIND] [--mode paper|live] — per-rule track record (n, win rate, R:R, expectancy, profit factor, max DD), an account/drawdown summary projected from gather_status, and a “distance to the promotion gate” view: how many more trades and which floors each paper rule still needs before it could be promoted to live.
  • keel insights journal [--json] [--rule] [--asset] [--limit] [--since] [--until] [--include-open] — chronological per-trade log with fee-honest net P&L, R-multiples, win/loss/open/scratch outcome, and DCA rows flagged “no stop — excluded from R/expectancy.”
Design
  • Read-only, zero new write paths. No orders, no rails/guards, no mutation of rule status / equity / agent_state. Repository gains no new methods — every read goes through existing get_rules / get_trade_outcomes / track_record / gather_status.
  • Pure VIEW, not a re-derivation. Rail-11 / drawdown / promotion floors are projected from existing outputs. Gate distance reuses can_promote + floor_for_class(promotion_class_of(rule)) verbatim — so the trend_follow floor (min_trades=100) is read, never hardcoded.
  • Testable core + thin click I/O, mirroring keel/commands/status.py / tui.py. Decimal throughout; --json is valid json.loads-able (full-precision Decimals via default=str, no disclaimer footer); human render quantizes money/ratios to 2dp.
  • Honest at small samples: an explicit “n<30: not yet statistically distinguishable from random entry” note; never implies a proven edge below the gate.
  • Self-contained: no TUI panel integration (deferred), no CTS/signals accessor (no clean join key; out of scope).
Tests

tests/commands/test_insights.py — 31 new tests (empty-DB-first, gate distance, DCA r_multiple=None, --limit pre-count, --include-open count line, small-sample note, ratio quantization, --json validity for both subcommands). Full suite: 1597 passed, ruff clean.

Review

Designed (Opus), built TDD (Sonnet), then reviewed end-to-end by an independent fresh-context Opus agent. Review found and this branch fixes: (1) journal count line was counting open rows as “closed” (showing N of M), (2) full-precision Decimal leaking into the human render, plus (3) the missing regression test. Verdict after fixes: read-only airtight, gate/promotion reuse correct.

feat(tui): add read-only insights overlay (i keybind) (#151)

What

Adds an i keybind to the live keel tui dashboard that opens a browsable, scrollable, read-only insights overlay — reusing the pure builders shipped in keel/commands/insights.py (PR #149). This is the deferred TUI follow-up to #149.

The overlay shows: account/drawdown summary, per-rule track record with distance-to-promotion-gate, the small-sample honesty note, and a recent-trades tail (last 5) — all as a live view that refreshes each poll.

  • Footer/keybind bar now advertises [i] insights.
  • Esc (or i again, or q) closes the overlay and returns to the dashboard; scrolling uses the same arrows/j/k/PgUp/PgDn/Home/End + clamped-offset infra as the existing help overlay.
Design / safety
  • Read-only, fail-soft. The overlay calls only read builders over the already-computed StatusReport + repo reads — no writes, no order/broker/network beyond the broker-free gather_status. The build is wrapped in try/except Exception (not KeyboardInterrupt), so a transient database is locked from the concurrent keel agent writer paints an error line and the loop keeps polling — mirroring the existing main-view handler.
  • Circular import avoided: insights.py already imports from tui.py, so the reverse import is TYPE_CHECKING-only + lazy in-function (same pattern tui.py already uses for cli↔tui). keel/commands/insights.py is unmodified.
  • keel tui --once is unchanged except the footer gaining the [i] hint.
  • Diff confined to keel/commands/tui.py + tests/commands/test_tui.py.
Tests

+12 tests (1597 → 1609), ruff clean. Covers: overlay open-on-i / close-on-Esc (asserts return to the dashboard), scroll offset movement, empty-DB friendly render, and the insights-branch fail-soft path (rigged database is locked → error line painted, loop survives).

Review

Designed (Opus), built TDD (Sonnet), independently reviewed by a fresh-context Opus agent → verdict: mergeable-as-is (read-only / fail-soft / circular-import / --once impact all verified clean). The two test-coverage NITs it raised (fail-soft path + Esc-close isolation) are included in this branch.

feat(proposer): keel assets propose — screen an LLM asset shortlist, admit nothing (#153)

What

Implements roadmap direction #3, slice (A): keel assets propose --from <shortlist.json> — a read-only candidate source that ingests an externally-produced LLM asset shortlist and routes each candidate through the existing, unmodified _screen_product admission gate.

Follows docs/superpowers/specs/2026-07-24-llm-asset-proposer-design.md and docs/superpowers/plans/2026-07-24-llm-asset-proposer.md.

Design — outside-first hybrid

The LLM + web-search scouting happens outside keel (operator / Claude + firecrawl skills produce the JSON shortlist). keel owns only the deterministic, testable half: validate → screen → report.

  • New pure module keel/proposer.py — parse_proposal (schema + per-candidate validation), build_proposal_report (routes each candidate through an injected screen_fn), render_proposal_report (human) + report_to_jsonable (--json). It does not import keel.cli (no cycle); the CLI passes the real _screen_product as the gate.
  • Thin assets propose command in cli.py, beside assets screen / assets holdings.
Guarantees (verified by an independent review — by construction, not just by test)
  • Read-only / admits nothing. No INSERT/UPDATE/DELETE, no attestation write, no allowlist mutation, no new Repository method. screen_asset / ScreenPolicy / _screen_product are byte-unchanged.
  • Same gate, by construction. assets propose and assets screen return the identical verdict for the same asset (test asserts ADMIT on both sides). Because attestation=None fails closed, a freshly-scouted asset REJECTs until a human attests it and data is fetched — the proposer surfaces candidates, it never admits them.
  • Provenance enforced by code. A candidate with zero source citations is rejected at schema validation, never screened.
  • shariah_hypothesis can’t influence admission. build_proposal_report passes only (repo, product, quote) to the gate; the hypothesis is rendered as an explicit UNVERIFIED (never used for admission) hint and can never become an attestation.
Out of scope (per spec)

No embedded LLM/web API, no new dependency, no second secret, no proposal-audit table, no allowlist mutation. Strategy-proposal (B) and insights/veto (C) are separate future specs.

Tests

+29 tests (1629 → 1658), ruff clean. keel/proposer.py unit tests (schema, builder, renderers, JSON) + CLI integration tests (same-gate equivalence, admits-nothing [reopens the DB], --json validity, hypothesis-never-admits, citation-required, hyphenated-asset rejection, non-dict entry, disclaimer on human path).

Review

Built TDD (Sonnet) from the plan, independently reviewed by a fresh-context Opus agent → mergeable-as-is. The 4 hardening items it raised are included: single-sourced DATA_DERIVED_FAILURES in screen.py (with a pin test so a tag rename breaks a test rather than silently disabling suppression), alphanumeric-asset validation, a stronger writes-nothing test, and two coverage tests.

Fixes

fix(orders): populate orders.rule_id from originating rule (Phase-2 debt) (#152)

What

Closes the long-standing Phase-2 debt where orders.rule_id (and signals.rule_id) were always written NULL. The column has always existed (FK → rules(id)), but the originating rule’s DB id was dropped early (_build_rule discarded row["id"]), so it never reached the insert.

This threads the real rule id through the chain — additively, defaulting None:

get_rules row id → Rule.rule_id (set in _build_rule) → Signal.rule_id (engine ENTER + agent EXIT) → OrderIntent.rule_id → the order-row insert. Also wires signals.rule_id in engine._persist_signal, and the paper path (enter/close, including restart-rehydrated positions).

Metadata-only — nothing about order placement changes

The only behavioral difference is a previously-NULL column now carries the rule id. No sizing, guard, rail, veto, confirm, or placement logic changed. Every hunk is either a new None-defaulted field, a hardcoded None swapped to the real id, or a docstring.

  • tests/execution/test_guards.py and tests/execution/test_reconcile.py have a zero-line source diff and pass unchanged.
  • test_rule_id_is_purely_additive_metadata_placement_and_guards_are_unchanged runs two otherwise-identical signals (with/without rule_id) through execute() and asserts identical placed / vetoed_by / broker preview+place calls / order_configuration — only the rule_id column differs.
Scope
  • Forward-only. No historical backfill migration; SCHEMA_VERSION unchanged. Old NULL rows stay NULL (unrecoverable by kind/name) — noted as a follow-up.
  • place_bracket / scale_out / _roll_stop still write rule_id=NULL (they only receive a rule_name, and rules.name has no UNIQUE constraint so a name→id lookup would be ambiguous). FK-safe; left as an accepted scope limit.
Tests

+20 tests (1597 → 1617), ruff clean. Covers: repository non-NULL round-trip, live ENTER + live EXIT order rows carry the seeded rule’s id, a full agent cycle, paper enter/close, restart-rehydration of a legacy-NULL position (no crash), and the additive-metadata placement-identical guarantee.

Review

Built TDD (Sonnet), then an independent fresh-context Opus safety review of the order path → verdict: mergeable-as-is. It verified the metadata-only guarantee (no rail reads rule_id), id correctness in every write path, FK-safety (PRAGMA foreign_keys = ON; all ids are real PKs or NULL), and backward-compat. The two MINOR test-coverage gaps it raised (live-exit rule_id, legacy-NULL rehydration) are included in this branch.

Docs, CI & tooling

chore(gitignore): ignore supervised-live sandbox operational files (#150)

Adds the three supervised-live sandbox operational files to .gitignore so they stop showing as untracked:

  • config.live-sandbox.yaml
  • keel-live-run.sh
  • com.keel.live.plist

These are the operator’s tiny-cap confirm-mode live-detector bundle (authored in the dev repo, deployed to ~/Documents/Keel). They mirror the already-ignored paper-forward operational files (config.paperforward.yaml / paperforward-run.sh / com.keel.paperforward.plist) and must never be committed. Their ignore lines had only ever lived in the working tree, so the three files were surfacing as untracked in every git status.

Diff is .gitignore-only. No code, no tests affected.

v0.2.0

Voir cette version sur GitHub ↗

Built from 9e77a5f00b49. Version binds to this hash: keel --version reports keel 0.2.0+9e77a5f00b49 [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.2.0-py3-none-any.whl
keel --version

⚠️ 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.

Features

Place orders via interactive confirmation (no bypass/passphrase) (#117)

Option A, as agreed: placing a live order no longer needs bypass mode, the arm-bypass token, or the dangerous-action passphrase. In confirm mode the agent shows the previewed order and asks; it places only on an explicit yes. Bypass mode is untouched and stays the headless path for future autonomous use.

The rails are untouched — this is important

Every order still runs the 15 hard rails first. The confirmation is an additional human gate after they pass, never a replacement. There’s a test pinning that a rail-vetoed order never reaches the prompt — the rails run first, the human second. So removing the bypass ceremony did not weaken any hard limit.

Why this was small

The executor was already built for it — execute takes confirm_fn(preview) -> bool; the agent just hardcoded it to None. This threads a real confirm_fn through run_once / loop / _handle_exits (all defaulting to None, so every existing caller and test is unchanged and still fails closed), and keel agent passes an interactive prompt in confirm mode.

_interactive_confirm renders the broker preview and asks; it fails closed on a non-TTY, so a script or cron never trades unattended.

Net effect

Placing an order goes from:

python3 -c "authz.set_passphrase(...)"   # no CLI for this
keel arm-bypass --passphrase ...
keel agent --bypass --passphrase ...

to:

keel agent
  Rails PASSED. Coinbase order preview:
    order_total: 5.00 ...
  Place this order? [y/N]: y

Arguably safer for supervised use — a human looks at the real order every single time instead of pre-authorizing a window.

Tests

approved → places the BUY (+ its OCO bracket); declined → nothing; no confirm_fn → still nothing (backward compat); confirm_fn sees the preview; a rail veto never reaches the prompt; _interactive_confirm yes/no/non-TTY; and the agent command wires the interactive prompt in confirm mode, None in bypass.

Follow-ups (not in this PR)
  • The go-live runbook (#116, unmerged) still describes the old bypass/passphrase dance and must be rewritten against this.
  • Still on your list: ship config.yaml in the release + a seed-the-db command — coming as a separate PR.

1266 tests pass (up from 1258), ruff clean.

feat(release): install scaffolding, PR-body release notes, a confirm-mode config asset, and a seed/migrate lifecycle (#118)

Everything a release ships, and how a fresh deployment comes up correctly.

Install scaffolding

A freshly installed wheel had no config to start from and an empty rules table — and with zero rules, the engine has no strategies to evaluate at all, however config.yaml is set.

  • keel init-config writes a config.yaml template shipped inside the wheel.
  • keel init scaffolds a working directory: config + seed the strategy (rules) library.
  • keel rules seed --status can seed at candidate (default), paper, or live; live bypasses the promotion gate and is for the supervised live-order test only, and says so loudly.

The notes used to be GitHub’s generate-notes output — * <title> by @author in #N — so learning what actually shipped meant clicking through every PR. Each entry now inlines the PR description under a ### <title> (#N) heading.

scripts/release_notes.py (repo-level; deliberately not shipped in the wheel) composes them, stripping the Claude Code footer and everything after it, HTML comments, and Co-Authored-By: trailers, and collapsing blank-line runs. A PR with an empty body renders _(no description)_ — visible, so it gets fixed rather than silently vanishing.

Grouping is unchanged and .github/release.yml remains its single source of truth: first matching category wins, norelease is dropped, unlabelled PRs fall to the catch-all.

Consequence worth internalising: the PR body is the release note. Write it for someone reading the release page.

config.yaml is now a release asset, in confirm mode

The Release attaches config.yaml — the production config: real allowlist and caps, in auto_trade.mode: confirm, so keel previews every order and waits for approval. It is ready for live use but cannot trade unattended off a fresh download; auto_trade.enabled also stays false.

It lives at keel/templates/config.live.yaml, committed and reviewed like any other code, and keel init-config --live writes the identical file locally. The dev template stays mode: paper (places nothing).

A release step fails loudly if that file is ever not confirm — shipping an armed config is precisely what this project refuses to do, so it is a build failure rather than a review question.

Seeding and migrating are separate, on purpose
keel init      # FRESH deployment: write config.yaml + seed the strategy (rules) library
keel migrate   # EXISTING database: apply outstanding schema migrations. Never seeds.

New keel migrate [--db PATH] is an idempotent, schema-only wrapper over the existing db.migrate(), reporting 0 -> 6 or already at 6, nothing to do. It is safe to re-run and safe against a live database.

It never seeds. Re-seeding on migrate would resurrect rules that were deliberately deleted or refuted — the dip-buyers removed after measuring them would come back. Seeded rules stay candidate and trade nothing until promoted.

New .github/workflows/migrate.yml is manual-only: give it a db_path and it migrates that database; leave it empty and it verifies the migration chain instead (a fresh DB and a downgraded DB both reach SCHEMA_VERSION).

Deliberately not done: wiring migration into release CI. keel.db is local, git-ignored and single-user, so CI has no database to reach. db_path is the seam for when the app is server-hosted; building the coupling now would mean a CI step with no target.

Verification

…truncated — full description in #118.

feat(security): delete the vault and the passphrase gate; autonomy becomes a profile choice (#119)

Removes both local security mechanisms from spec §14 and replaces them with one rule: dangerous actions need a human at a terminal; nothing needs a stored secret.

The rails are untouched. All 17 still run first, un-overridable, in every mode. This changes who is asked, never what is allowed. Every claim below has a test.

The vault is gone — and it was already dead code

keel/security/secrets.py (AES-GCM secrets.enc) is deleted, along with the now-unused cryptography dependency.

Worth stating plainly, because it changes how you should read the diff: the vault was never on the live path. data/cb_client.py and cli.py have always loaded credentials via config.load_secrets() from a git-ignored .env; the vault was reachable only through migrate_from_env. Deleting it removes a competing credential path rather than changing how credentials are actually loaded. (PR #115, which would have wired it up, was closed for this reason.)

The passphrase gate is gone

keel/security/authz.py is deleted. Of its four declared dangerous actions, raise_caps and unlock_vault were never used at all — caps are config-file-only, and the vault had no CLI surface. arm_bypass disappears with bypass mode.

That left four commands, all one idea — re-permitting trading after a safety halt: resume, resume-entries, record-flow, reset-hwm. Each now demands a typed yes (not a bare y) and fails closed off a TTY.

The reasoning is coherence. Since #117, placing a real, money-spending order needs only a typed confirmation. Requiring a remembered secret to reset a high-water mark is ceremony without a matching threat model — and the old gate’s own docstring conceded it “does not stop an attacker who already holds the OS account”. One rule is easier to audit than two.

⚠️ There is deliberately no env-var or flag override for the TTY check. I started to add one as a test seam and rejected it: any such seam is settable from cron and would defeat every fail-closed built on it. Tests patch the predicate instead.

Autonomy is a profile choice, not a config mode

New profile table (schema 6 → 7) holds the user’s autonomous flag.

keel autonomy show
keel autonomy on     # typed "yes", terminal required
keel autonomy off    # always allowed, works from a script or cron

auto_trade.mode collapses to paper | confirm, so there are two independent switches rather than one enum conflating both:

modeautonomousresult
paper(ignored)simulated, places nothing
confirmfalse (default)live, asks before every order
confirmtruelive, places without asking

Four properties, each tested:

  • Read live, never cached — _effective_mode re-reads the profile every cycle, so keel autonomy off binds on the next order, not the next restart. (Mirrors rail 14’s allowance, moved to the DB for the same reason.)
  • Fails closed — an absent or deleted profile row reads as not-autonomous. A database that never recorded a choice must not imply consent.
  • Enforced in-process — the check lives inside run_once, not only at the CLI, so a caller driving run_once directly cannot obtain autonomy the CLI would refuse.
  • Not scriptable to enable — autonomy on requires a terminal. off deliberately does not: reducing risk must never be obstructed.
Autonomy does not clear safety halts

…truncated — full description in #119.

feat(assets): the user’s broker holdings as a candidate source (#120)

Adds the missing source for allowlist candidates: the assets you actually hold at the broker.

keel assets holdings [--min-balance N] [--screen]

It answers “what do I already own that this system might trade?”, where keel assets discover answers “what could anyone trade?”.

A source, not a gate

Holding an asset is not a reason to trade it. This command admits nothing and writes nothing — no attestation, no allowlist change, no DB write. It is a read-only report, in the same family as assets discover.

The vetting itself is unchanged. screen_asset and its policy are untouched: if this work had needed to weaken the gate to let the user’s own holdings through, that would have been evidence against the holdings, not against the gate. An unattested asset you hold is still REJECT — sector and backing cannot be derived from a balance any more than from a price.

2 holding(s) above 0, excluding USDC and fiat:

  BTC      balance=0.5      on-allowlist     UNATTESTED
      REJECT  (2000 daily bars cached)
      x attestation: MISSING. ...
  SOL      balance=12       not-on-allowlist UNATTESTED
      REJECT  (0 daily bars cached)
      ! no local history -- run `keel fetch --products SOL-USD` first, then re-screen.
        This is a MISSING-DATA verdict, not a verdict about the asset.
One gate, shared by construction

assets screen inlined the attestation lookup and the screen_asset call. That is extracted to _screen_product, and both commands now route through it — so a proposer cannot drift onto a laxer path. A test asserts the two commands reach the same verdict for the same asset.

This is also the seam the future LLM proposer must use. Recorded in the spec: per the project’s asymmetry principle an LLM may propose and may veto, but may never admit. Admission still requires a human keel assets attest with a source, a passing screen, and a deliberate config.allowlist edit. Nothing here grants a proposer new authority.

Two things found while building it
  • Screening {asset}-{quote_currency} would have made the feature useless. Daily history is keyed -USD throughout this codebase (_default_sim_products, keel fetch, keel simulate), while quote_currency is USDC. Screening the settlement product would have found zero cached bars for every asset and reported “no local history” forever — which is worse than useless, because it reads as a verdict about the asset. The convention is now single-sourced in _history_product, and my own test caught this by asserting the two commands agree.
  • “No local history” is not “bad asset”. The likeliest misreading of the whole feature, so it is handled in the output rather than left to the operator, and it names the keel fetch that fixes it.
Verification

1300 tests pass, ruff clean. Nine new tests: fiat/settlement-currency exclusion, dust filtering, held-but-unattested is still rejected, both commands agree on one asset, the missing-data message, the command writes nothing (asserted, not assumed), and a broker failure is an error rather than an empty result that would falsely read as “you hold nothing”.

Design: docs/superpowers/specs/2026-07-21-holdings-as-candidate-source-design.md.

Fixes

fix(rails): rail 13 must guard the currency the order actually spends (#121)

Found during the first supervised live-order attempt. It looked like three separate quirks; it is one design defect with a live-money safety hole in it.

The defect

The codebase conflates two different things:

  • the product’s quote leg — what an order actually spends (BTC-USD spends USD);
  • config.quote_currency — one global setting (default USDC).

Nothing derived the first from the product, so rail 13 guarded a balance the order never touches:

  • False veto (observed live): $49.01 USD available, $0.25 USDC, a $5 BTC-USD order → vetoed. Annoying, safe.
  • False pass (the serious one): ample USDC, no USD → the rail approves an order the account cannot fund. That is exactly the “never draw from a linked bank/ACH source” case rail 13 exists to prevent. A safety rail that can pass when it should veto is worse than no rail, because it is trusted.
The fix

One rule: the currency an order spends is a property of the PRODUCT, never of global config.

  • New keel_core.products.quote_currency_of(product_id) → the leg after the last -, uppercased; None for anything unresolvable.
  • The executor fetches the balance of that currency; rail 13’s fail-closed semantics are unchanged, and an unresolvable product id yields None, which it already vetoes on.
  • The violation message names the currency actually required. Telling an operator “insufficient USDC” on a USD-settled order sends them to fund the wrong thing.
available USD 0     -> usdc_funding: available USD balance 0 is not greater than 0
available USD 1000  -> PASS
available UNKNOWN   -> usdc_funding: available USD balance is unknown/unavailable -- failing closed
malformed id        -> usdc_funding: cannot determine the settlement currency of 'BTCUSD' -- failing closed
Two consequences of the same root cause

The screen’s settlement criterion was vacuous. quotable_in_settlement_currency = product.endswith(f"-{quote}") or bool(candles). Every screened product is -USD while quote was USDC, so it always fell through to bool(candles) — meaning require_settlement_quote re-checked “do we have bars”, which the history criterion already covers. One of four admission criteria did nothing. The fallback is removed; the check is now real. (This was recorded as a known weakness in PR #120 and deliberately deferred — this is that follow-up.)

quote_currency now defaults to USD (repo config, both templates, Config, DiscoveryPolicy), because that is what this deployment actually trades — products, cached history, rules and the simulator are all -USD. With the settlement check real, leaving it USDC would reject every asset. A default has to describe reality.

Also
  • Stablecoins join fiat in the holdings exclusion. Cash held between positions is not a position. Closes a limitation flagged in #120’s review (USDT/DAI surfacing as candidates), and prevents USDC being offered as tradable now that it is no longer the settlement currency.
  • The operator’s working config moves to a gitignored config.local.yaml. config.yaml is the source of the shipped wheel template and should never carry one machine’s live settings — I had committed mine by accident with git add -A.
Verification

…truncated — full description in #121.

Other changes

chore: require Python 3.14.4; move ruff config to ruff.toml (#122)

Raises the Python floor from >=3.12 to >=3.14.4 and moves ruff’s config out of pyproject.toml into a root ruff.toml.

What changed
  • requires-python → >=3.14.4 in the root project and all four workspace members. Leaving a member at >=3.12 would give it a laxer floor than the workspace actually resolves against.
  • ruff.toml (new) holds line-length, target-version = "py314", and the E,F,I,UP / ignore = ["UP042"] lint config. The [tool.ruff] block in pyproject.toml is deleted, not duplicated — a ruff.toml takes precedence over [tool.ruff], so keeping both would leave the pyproject copy silently dead.
  • [tool.mypy] python_version → "3.14" to match.
  • .python-version and uv.lock both had 3.12 baked in and contradicted the new floor (uv refuses to sync in that state), so both are regenerated.
Notes for review

No source changes were needed — this is config only.

The uv.lock diff looks large but contains no version changes. 66 of its 137 removed lines are cp312 wheel URLs that are unreachable under the higher floor; the rest is the same pruning for other dropped interpreter tags. Every package version is byte-identical before and after.

Verification on 3.14.4
python --version  → Python 3.14.4
ruff check .      → All checks passed!
pytest -q         → 1322 passed in 8.36s
mypy              → Success: no issues found in 177 source files
keel --help       → CLI loads, broker entry points resolve

coinbase-advanced-py and cryptography==49.0.0 both ship 3.14 wheels, so nothing builds from source.

refactor(cli): decompose the 2627-line cli.py into a keel/commands package (#123)

Summary

keel/cli.py had grown to 2627 lines — seven click command groups plus ~15 top-level commands and their shared helpers in one file. This splits the broker-free groups into their own modules under a new keel/commands/ package, leaving cli.py a thin composition root.

keel/cli.py: 2627 → 1688 lines (−36%). No behavior change.

What moved
ModulePurpose
commands/trials.pytrials group (ledger-only, seam-free)
commands/rules.pyrules lifecycle group (rules_seed still invoked by init)
commands/subscription.pysubscription group (rail 14)
commands/autonomy.pyautonomy group (+ its _AUTONOMY_HOURS constants)
commands/withdrawals.pywithdrawals group (rail 17)
commands/db.pydb import group
commands/_common.pyShared seams: disclaimer, confirmation gate, _open_repo/_load_cfg/_build_broker
commands/_products.pyShared product-id derivation

Each group is registered on the root CLI via cli.add_command(...).

Deliberately kept in cli.py

The assets group and the broker-touching top-level commands (fetch, agent, monitor, simulate) stay in the composition root so the _build_broker monkeypatch seam remains one coherent target rather than fragmenting across modules.

Seam preservation (the delicate part)
  • _build_broker / _open_repo / _load_cfg stay patchable as keel.cli.X — cli.py re-imports them and every caller of these remains in cli.py.
  • _is_interactive (called internally by the confirmation gate) is routed through _common._is_interactive() as a single patch point; three test files were retargeted to keel.commands._common._is_interactive.
Verification
  • ✅ ruff check . clean
  • ✅ mypy keel packages clean
  • ✅ pytest — 1322 passed
  • ✅ CLI smoke test: every subcommand still resolves

Each extraction step was reviewed by a sub-agent (zero defects), plus a final holistic review.

chore: pin python 3.14.4 in a repo-local .tool-versions (#124)

Pins python 3.14.4 in a repo-local .tool-versions so the interpreter is resolved from the checkout itself, keeping asdf/mise in lockstep with pyproject’s requires-python = ">=3.14.4" regardless of a contributor’s global config.

Previously the version was only pinned in ~/.tool-versions; a contributor without that global pin (or with a stale one) could land on the wrong interpreter.

chore: drop .tool-versions; standardize on .python-version + pyproject (#125)

Removes the repo-local .tool-versions added in #124. It duplicated the exact 3.14.4 pin already in .python-version, and two concrete pins can silently drift.

The project standardizes on .python-version (read by uv, the project’s toolchain) alongside pyproject’s requires-python = ">=3.14.4" range. asdf continues to resolve 3.14.4 from the user’s global ~/.tool-versions; uv run continues to resolve it from .python-version (verified).

fix(agent): report paper cycles as mode=paper, not mode=confirm (#126)

Problem

Running keel agent with the default config.yaml (which is mode: paper) printed:

mode=confirm polled=0 products=[] stale=[] signals=0 entered=0 exited=0

The mode=confirm was misleading — it told the user a live confirm-mode run had happened when the cycle only ever hit the paper simulation path and never touched the broker. (This directly caused a support question: “why didn’t it place the order in Coinbase?”)

Root cause

_effective_mode returns the executor string "confirm" for any non-live config — paper included — because paper never reaches an executor (it routes to PaperTrader upstream). run_once surfaced that raw value in both the agent.mode_resolved log event and LoopResult.mode.

Fix

Split “what the executor uses” from “what we report”:

  • mode (executor mode) is unchanged and still passed to executor.execute / _handle_exits on the live path.
  • A new reported_mode = "paper" if paper_trader is not None else mode drives the log event and LoopResult.mode.

Confirm and autonomous runs are unchanged.

Verification
  • New test test_paper_mode_is_reported_as_paper_not_confirm (written failing first).
  • Full suite: 1323 passed.
  • Manual: default config.yaml now prints mode=paper; --config config.local.yaml still prints mode=confirm.

docs(kb): source-84 — keeks bankroll-management (Kelly family) + sizing simulation (#127)

What

Enriches the trading knowledge base with the keeks library (v0.3.0) and its 9-part “Bankroll Management with Keeks” series — the Kelly Criterion family of capital-allocation sizers.

Halal framing: adopted strictly as the mathematics of optimal capital allocation, never betting (maysir). Educational only; nothing is wired into the live agent — the paper-proving gate + backtest floor still bind.

Contents
  • sources/source-84.md — disclaimer; per-strategy formulas (full / fractional / drawdown-adjusted Kelly, optimal f, Merton/CRRA, fixed fraction, CPPI, dynamic, naive); halal screen; keeks API reference; and educational commands to explore the rules & strategies.
  • README.md — sources-log row 84 + a new module-map row for execution/sizing.py.
  • analysis/bankroll_sizing/ — stdlib-only sizing formulas + Monte-Carlo (simulate.py) + 38 unit tests (all pass via uv run pytest).
  • reports/2026-07-22-bankroll-sizing-comparison.md — the simulation write-up.
Verdict (like §83: confirms the risk model, does not reshape it)

keel’s fixed-fractional risk_pct=0.01 is the Fixed Fraction strategy. At keel’s own promotion floor (win_rate 0.55, R:R 1.5), full-Kelly f* = (1.5·0.55−0.45)/1.5 = 0.25 → keel’s 1% is ~4% of full Kelly — a 4th independent “use fractional f, never full” confirmation (§54.18/§83.5/§83.11). The simulation’s estimation-error stress run (true p 5pts below estimate) collapses full-Kelly growth and lifts its ruin rate to 3.6% while the sub-Kelly levels stay at 0% ruin — defending keel’s deeply-sub-Kelly posture, not a recommendation to change risk_pct.

Test plan
  • uv run pytest docs/superpowers/analysis/bankroll_sizing/test_sizing_strategies.py -q → 38 passed
  • uv run python docs/superpowers/analysis/bankroll_sizing/simulate.py regenerates the report

chore: add review-fix-merge PR review skills (#128)

Adds two project skills for reviewing + merging PRs, so they live with the repo instead of only in a local .claude/.

Note: .claude/ is gitignored (line 26), so these were force-added (git add -f). They stay tracked and their edits show normally; new files added inside these skill dirs later would need another git add -f.

Skills
  • review-fix-merge-pr — single-reviewer loop: review a PR’s diff → fix findings → loop review↔fix until clean → squash-merge. Bundles a parameterized Workflow script (review-fix-loop.js).
  • review-fix-merge-pr-advanced — multi-lens variant: parallel reviewers each backed by a specialized skill (security-audit, performance, code-quality, testing-strategy, correctness), adversarial verification of each finding before fixing, loop, then merge (advanced-review-fix-loop.js).

Both scripts are syntax-checked; invoked via /review-merge-pr... or by asking to review-and-merge a PR.

docs(kb): explore source-84 leads — drawdown taper (kept) + Merton γ (promoted) (#129)

Measures the two candidate leads flagged in KB source-84 §84.16, in the KB’s “settled by measurement” style. Adds a stdlib-only study (explore_leads.py, +18 tests → 56 in the dir) and a report, and folds the verdicts back into source-84 §84.4/§84.6/§84.16 + the README index row.

Halal/educational framing preserved — the Kelly family studied as the math of capital allocation, not betting; nothing is wired into keel’s execution path.

Lead 1 — dynamic drawdown taper (1−d/D)·f* → KEEP as ceiling/diagnostic (not built)
  • Barely engages on keel’s 1% base (drawdown rarely reaches even D=0.15; breaker-trips 0% either way) — confirming its value isn’t protecting the tiny base.
  • Its payoff is unlocking a higher base: tapered quarter-Kelly at D=0.15 (below keel’s 20% hard breaker) dominates both flat-1% on growth (4.27× vs 2.08×, profile A) and untapered ¼-Kelly on safety (median DD 14.9% vs 22.75%; hard-breaker trips 99.8%→0%).
  • Strictly conditional: dominance needs D < the 20% breaker (fails at D≥0.25 and in the p-over-estimated world); a half-Kelly base is too large for any taper to rescue. Only pays off if the base is ever raised off 1% — which §58.11/§84.14 argue against. Parked, with a number.
Lead 2 — Merton γ μ/(γσ²) → PROMOTE to diagnostic (not a risk_pct change)
  • keel’s implied γ ≈ 24 (floor edge) / ≈ 34 (stronger edge) — 24–34× more risk-averse than full Kelly (γ=1), 12–17× past textbook γ≈2. A crisp “how sub-Kelly are we” number.
  • A single fixed γ is edge-and-variance-aware: γ=24.24 sizes the stronger/lower-variance edge at 1.39% vs the floor’s 1.00%, automatically — which flat-1% cannot do.
  • Effective λ = 4.00% / 3.76% of full Kelly, matching keel-1%‘s own ~4% figure; ruin stays 0% under the p-over-estimated stress (graceful, like fractional Kelly).
Test plan
  • uv run pytest docs/superpowers/analysis/bankroll_sizing/ -q → 56 passed
  • uv run ruff check docs/superpowers/analysis/bankroll_sizing/ → clean
  • uv run python docs/superpowers/analysis/bankroll_sizing/explore_leads.py regenerates the report (deterministic)

docs(readme): add ‘How keel works’ section (#130)

Adds a How keel works section to the root README, filling a real gap: the operating model (how orders actually get placed, why there’s no manual trade command, DCA’s calendar cadence) was previously only reconstructable from the design spec, runbooks, and code docstrings.

Covers, in a screenful:

  • The agent loop — poll → each live rule’s detect() → rails → preview → confirm/autonomy gate → place_order → log.
  • Rules-only, no manual order command — every order is rule output; the four rule kinds and that DCA is the one on a calendar cadence (the others fire on real market setups).
  • The un-overridable rails — allowlist, spend/exposure/concentration caps, kill-switch, drawdown & edge-decay breakers, no-martingale/no-stop-widening, feed/balance/attestation checks.
  • Confirm vs autonomy — changes who is asked, never what’s allowed.
  • Halal + ships inert — long-only spot, no riba; nothing trades until a rule is live, attested, funded, and confirmed.

Verified against guards.py, agent.py, executor.py, and the runbooks so it doesn’t drift. Docs-only.

docs(runbook): note the DCA cadence gotcha (signals=0, no preview) (#131)

The go-live runbook’s step 4 promises an order preview, but a DCA test vehicle only fires on its calendar cadence — (latest_daily_bar_epoch_day % cadence_days) == 0, i.e. Thursdays (UTC) for the weekly default. Run the agent on any other day and you get signals=0 with no prompt, which reads as a failure (it isn’t).

Adds:

  • a pointer in step 4 for when you see signals=0 instead of a preview;
  • a “What can still go wrong” bullet explaining the cadence gate, how to fire it on demand (cadence_days=1, reverted after — a one-row rules update that places no order), and that the risk-defined rules fire only on real market setups, so DCA on a cadence day is the controllable path for a first live order.

Docs-only; verified against keel/strategy/rules/dca.py.

source-84’s cross-references pointed at docs/superpowers/specs/2026-07-15-halal-cb-autotrade-design.md, a stale filename that doesn’t exist after the halal-cb → keel rename. Repointed to the real 2026-07-15-keel-autotrade-design.md (matching the root README). Found via a session doc-drift audit; it was the only KB/runbook reference to the broken name.

The security-simplification design’s ‘files touched’ table pointed at docs/superpowers/specs/...-halal-cb-autotrade-design.md, stale after the halal-cb → keel rename. Repointed to the real 2026-07-15-keel-autotrade-design.md. This was the last remaining reference to the old name anywhere in the repo (verified by grep).

feat(compliance): documented allowlist-screen exceptions (waivers) (#134)

What & why

Adds a general, per-asset / per-criterion allowlist-screen exception mechanism, plus keel assets exempt / unexempt to record and revoke one.

Motivating case: PAXG passes shariah (ayn, KB §65.5/§72.4/§86) and liquidity screening but fails the 4-year daily-history floor (441 on-chain -USD bars < 1460). Gold has centuries of off-chain price history and PAXG is a thin tokenised wrapper, so a human wants to record a documented, auditable exception that waives only the history criterion — never a silent exemption.

Design — honest and tightly bounded
  • New table screen_exceptions (asset, criterion, rationale, granted_by, granted_at; PK (asset, criterion)). Schema v8→v9 via a documented no-op migration (the table DDL is in the schema block; migrate() creates it on existing DBs before the version loop — mirrors the v6/v7 attestation/profile pattern).
  • WAIVABLE_CRITERIA = frozenset({"history"}) — only a DATA/market criterion may ever be waived. The shariah core (missing attestation, haram_sector, riba_yield, dayn/unknown backing) and settlement can never be waived. Enforced two ways: the CLI --criterion is a click.Choice restricted to this set, and screen_asset only reads a waiver inside the history branch (structural isolation) and checks membership — belt-and-suspenders.
  • Surfaced loudly, never silent: a waived history failure becomes a ! ... WAIVED by documented exception: <rationale> warning and the verdict flips to ADMIT. Self-retiring: the waiver is only consulted when the check would otherwise fail, so once -USD bars ≥ 1460 it becomes inert automatically.
  • Ungated like assets attest (an exception cannot itself place an order; the 17 guards.py rails still enforce every trade). Revocable via unexempt (a de-risking action). Shown in assets list.
Tests (TDD, written first)
  • screen: waiver admits + warns loudly; self-retires when bars ≥ floor; a waiver for a non-waivable criterion fails closed; a waiver rescues only history (other failures still REJECT).
  • repository: upsert/get/list/delete round-trip, ON CONFLICT update, asset-scoping.
  • db: fresh + upgraded DBs reach v9 with the table; migrate idempotent.
  • cli: exempt → screen ADMITs with WAIVED; bad --criterion rejected; list shows it; unexempt reverts to REJECT.
  • Mutation-checked the WAIVABLE_CRITERIA guard (emptying it fails the positive-path tests).

uv run pytest -q → 1399 passed; uv run ruff check → clean.

feat(config): expand live allowlist to 8 compliance-cleared assets (#135)

What

Wires the compliance-screened assets into the operative trading allowlist (guards.py rail 1 enforces config.allowlist). Adds SOL, XLM, LTC, ADA, LINK to the existing BTC/ETH/PAXG → 8 assets.

All eight are screen-ADMITTED: attested (KB §85/§86; PAXG ayn + a documented history-floor exception) with ~5yr of cached daily+hourly data. keel assets screen → 8/8 admit.

Weights

target_weights rebalanced across the 8, summing to exactly 1.00:

BTCETHPAXGSOLXLMLTCADALINK
.30.20.20.06.06.06.06.06

~70% in the established BTC/ETH/PAXG core (PAXG the real-asset diversifier, §83.10), 30% spread evenly across the five alts. Weights drive the DCA benchmark reference, not live sizing (the Turtle sizes by ATR/risk_pct).

Scope
  • config.yaml (dev) + byte-identical keel/templates/config.yaml + keel/templates/config.live.yaml — updated in lockstep. Dev stays mode: paper, live stays confirm; top-level keys unchanged. No logic change.
  • Golden baselines and the parser fixture tests are independent (their own YAML) — untouched.
Safety

The 5 new assets have no live rules yet. keel rules seed (run locally, post-merge) creates candidate Turtle rules that must pass backtest → paper → promotion before any live trade. Expanding the allowlist makes them eligible, not live.

uv run pytest -q → 1410 passed; uv run ruff check → clean.

feat(paper): synthetic account + Rail 11 drawdown enforcement + sizing fix (#136)

Paper-mode fidelity: synthetic account + Rail 11 enforcement + sizing fix

Makes paper trading a faithful rehearsal of live so the paper-forward produces a trustworthy out-of-sample track record for the promotion gate.

Spec: docs/superpowers/specs/2026-07-23-paper-mode-fidelity-design.md Plan: docs/superpowers/plans/2026-07-23-paper-mode-fidelity.md

What & why

Two coupled defects in the paper path, fixed together (Rail 11 needs an equity denominator, which needs real sizing, which needs per-position qty):

  1. Rail 11 (the drawdown circuit breaker) was inert in paper — the agent hard-set equity_now = None in paper mode, so update_drawdown never ran and the breaker read a frozen 0. A catastrophic drawdown could run a paper strategy into the ground uncapped, polluting the OOS record.
  2. Paper mis-sized its fills — it built a risk-sized intent only to gate the guard check, then filled a fixed 1 unit, with no cash balance. Drawdown-as-a-percentage was undefined.
How

A synthetic paper account (cash + qty-bearing positions, persisted in agent_state) is seeded once from real broker mark-to-market equity (fallback paper.starting_equity_usd), marked to market each cycle, and fed into the existing equity.update_drawdown producer — which writes the same global scalars guards.py’s Rail 11 already reads. guards.py and the DB schema are unchanged. Fills are sized off account equity via _build_intent(equity_override=...) (live path unchanged). Mode-flip clears the shared HWM so a synthetic HWM can’t poison live equity. Halt = veto new buys; open positions ride to their stops (mirrors live).

Design decisions
  • Paper sizes off its synthetic account equity (like the sim), not the $5k max_exposure proxy.
  • Seeded once from real equity at start; loop is broker-free thereafter.
  • Observability via LoopResult fields + _print_loop_result + an agent.paper_equity log event (a dedicated keel status command is deferred).
Testing

1447 tests pass, ruff check clean. Includes an end-to-end acceptance test: a paper account driven to −20% / −8% through the real run_once loop gets its buys vetoed by Rail 11.

Process

Built subagent-driven, 10 TDD tasks, each spec+quality reviewed. The final whole-branch review found and fixed a Critical the per-task reviews missed (an epoch cutoff that mixed bar-time and wall-clock, dropping first-cycle positions on rehydration and desyncing cash) — resolved with an id-based epoch and independently re-verified.

Deferred follow-ups (non-blocking)
  • Pre-live-arming prerequisite: the live-side mode-clear is asymmetric; a paper→live flip with an unreadable first cycle leaves stale paper scalars for one cycle (self-heals; live not yet armed). Gate the live clear on == "paper" and hoist before the broker read before arming live. TODO left in agent.py.
  • Non-goals (Phase-4): fix the live executor’s $5k-proxy sizing to use real equity; unify SimAccount and the paper account.
  • Minor cleanups: dead paper_ledger_start_ts field, a few test-hygiene nits.

fix(agent): symmetric live-side equity-mode clear (pre-live-arming) (#137)

What

Makes the live-side equity-state mode clear in run_once symmetric with the paper-side clear (_seed_paper_account_if_needed). This is the pre-live-arming fix flagged by a TODO(pre-live-arming) in keel/agent.py — harmless today (live is never armed) but must land before any live execution.

The bug

The old live-side clear lived inside the equity-readable branch and was gated on != "live". On a paper→live flip whose first live cycle reads an unreadable broker, equity_now is None, the whole branch is skipped, and stale paper drawdown scalars survive one extra cycle before self-healing — arming Rail 11 on a phantom drawdown for a cycle.

The fix
  • New _clear_live_mode_if_needed(repo) mirrors the paper-side clear: gated on the prior mode being "paper", hoisted to the top of the live branch, before the broker-equity read, so it fires unconditionally on the flip regardless of broker readability.
  • Deleted the old inline clear + stale TODO.
Tests
  • New regression test test_paper_to_live_flip_clears_stale_scalars_even_when_broker_unreadable — confirmed failing before the fix, passing after.
  • Full suite: 1448 passed, ruff clean. All 5 adjacent drawdown/mode tests unchanged and green.

feat(cli): keel status — read-only operator dashboard (#138)

What

Adds keel status, a read-only operator dashboard — the interim of the TUI/keel status command explicitly deferred in the paper-mode-fidelity spec (“A dedicated keel status command is deferred as a follow-up”). Purpose: let an operator running a paper-forward see the agent’s state at a glance, purely from the local DB + config — it never calls the broker.

Shows
  • mode (paper/confirm) · kill-switch (engaged/clear, fail-closed) · autonomy (mirrors keel autonomy show)
  • Rail 11: equity_state_mode, HWM, total/weekly drawdown vs config ceilings, and a computed HALTED / ok / unknown breaker status; paper_cash_usdc in paper mode
  • open positions · rule counts by status (+ live rules) · per-product data freshness (finest granularity, age)
  • subscription/allowance (rail 14) when any venue is attested
Design
  • Pure gather_status(repo, config, now_ts) -> StatusReport (no click, no broker) + render_human + a --json flag (stable machine-readable shape, forward-compat with the eventual TUI). Matches the codebase’s logic/CLI separation.
  • Rail 11 display deliberately shows unknown for unwritten scalars rather than defaulting to 0/”ok” like guards.py’s veto path — a dashboard must not misreport an uncomputed value as a confident all-clear. guards.py was read for reference only, not modified.
Tests
  • 18 pure-function tests + 2 CliRunner tests (incl. --json parses as JSON). Full suite 1467 passed (+20), ruff clean.

feat(rules,agent): funded paper-forward enablement (#139)

What

Two small, cohesive capabilities that together let us start a funded paper-forward of the 5-trend Turtle — the honest path to the n=100 out-of-sample evidence floor.

1. keel rules promote --force

The lifecycle is candidate→paper→live, and promote re-runs the backtest gate (min_trades=100). A low-frequency trend-follower’s backtest yields far fewer than 100 trades, so it can never enter paper status via the gate — yet the whole point of a paper-forward is to accrue the out-of-sample trades the backtest can’t. --force advances one lifecycle step directly (via repo.update_rule_status), with a loud warning + rules.promote_forced log event. Analogous to the existing seed --status live gate-bypass. No-op on live/disabled. Non-force path unchanged.

  • New public helper promotion.next_status(status).
2. starting_equity_usd as a funding override for the paper seed

_seed_paper_account_if_needed seeded the paper account from real mark-to-market equity, using starting_equity_usd only as a broker-failure fallback. Now: starting_equity_usd > 0 funds the paper account at that amount (a deliberately-funded rehearsal, no broker read needed); == 0 keeps the real-equity default. Committed config.yaml value stays 0 — only the comment/docstrings changed.

Tests (TDD)

12 new tests: force-promote candidate→paper with zero candles (backtest would never clear the floor), paper→live, no-op on live/disabled, the loud warning, non-force still gates; funding override wins over a broker reading real equity; starting_equity_usd==0 still seeds from real equity. Full suite 1480 passed (+12), ruff clean.

feat(cli): keel tui — live read-only operator dashboard (#140)

What

Adds keel tui — a full-screen, auto-refreshing, read-only terminal dashboard for watching a running agent / the funded paper-forward at a glance. It is built directly on the pure gather_status(...) -> StatusReport that keel status (PR #138) was deliberately left as the substrate for, and is strictly a view over that same report (it never re-derives Rail 11 / freshness / autonomy logic).

Shows: mode, kill-switch, autonomy, Rail 11 drawdown/equity state, open positions (bracket-less positions flagged), rule counts + live rules, per-product data freshness (stale series flagged), and subscriptions — colour-coded and refreshed on an interval.

Why now

The funded paper-forward (5-trend Turtle, $10k + $500/mo) is live and accrues trades toward the n=100 floor over months. Watching it meant re-running keel status by hand; this is the auto-refreshing dashboard the user asked for, and it’s unblocked today — no monorepo split or keel-client protocol needed.

Design
  • Pure, testable core: build_screen/render_plain/_freshness_style are pure functions of the StatusReport; the curses I/O (_paint/run_live) is a thin layer tested against a fake stdscr.
  • No new dependency: stdlib curses only, in keeping with the project’s hand-rolled ethos (no rich/textual). curses is imported lazily so the module stays importable and the core tests stay portable.
  • Strictly read-only: NEVER calls the broker or touches the network; cannot confirm/kill/arm anything (that’s a separate, larger, gated feature).
  • Operational robustness: --once renders one frame to stdout (pipes/CI); --interval N sets cadence; the live loop re-opens the repo each poll (so it reflects a separate keel agent’s committed writes) and survives a transient DB read error (e.g. database is locked from the writer) by painting an alert line instead of crashing.
Testing
  • 39 new tests (tests/commands/test_tui.py); full suite 1519 passed, ruff check clean.
  • Includes regression guards for two issues an independent review caught: colour was silently disabled (missing use_default_colors() → -1 background illegal under curses.wrapper), and the live loop crashing on a transient SQLite lock.
  • keel tui --once verified against the real keel.db.

Design spec: docs/superpowers/specs/2026-07-24-tui-dashboard-design.md

fix(cli): keel tui fails gracefully without an interactive terminal (#141)

What

Follow-up to #140. Running keel tui (the live, full-screen path) without a real controlling TTY dumped a raw traceback:

_curses.error: cbreak() returned ERR

This surfaced when launching it through a pipe / a harness that captures stdio rather than owning a terminal. Now it fails with a clean, actionable message instead.

Fix
  • Pre-check _stdio_is_interactive() (both stdin and stdout are TTYs) in tui_cmd before entering curses; if not, raise a ClickException pointing the user at keel tui --once.
  • Belt-and-braces: run_live also catches curses.error raised by curses.wrapper itself — the case where a terminal passes isatty() but still can’t be put into cbreak mode — and turns it into the same helpful message.

--once (the non-curses snapshot path, used by pipes/CI) is unaffected.

Testing
  • +3 tests (_stdio_is_interactive both-TTY logic; keel tui under CliRunner exits cleanly with a --once hint; run_live wraps a curses.error from wrapper as a ClickException).
  • Full suite 1522 passed, ruff check clean.
  • Verified: piped keel tui now prints Error: keel tui needs an interactive terminal … use \keel tui —once`instead of a traceback;—once` still renders a snapshot.

feat(cli): interactive keel tui — human-readable time, help menu, actions (#142)

What

Turns the read-only keel tui dashboard into an interactive operator console.

  1. Human-readable timestamps — now=, position opened_at=, and autonomy lapse times now render as YYYY-MM-DD HH:MM:SS local time (freshness keeps relative “4h ago”).
  2. Keybinding hint bar + a browsable, scrollable help overlay (h/?; arrows / j/k / PgUp / PgDn / Home / End; Esc/h to close).
  3. Actions, each with the same asymmetric safety gating the CLI already enforces:
    • a toggle autonomy — turning OFF is instant (de-risk, ungated); turning ON suspends curses and runs the same typed-yes gate as keel autonomy on, showing mode + allowlist, and fails closed on any error (EOF, exception during suspend/restore, Ctrl-C) so it can never arm silently. Hard rails are untouched — autonomy only changes who is asked.
    • f fetch-all-data — money-safe (ensure_history pulls candles, places no orders); shows a progress frame + result toast.
    • r refresh-now.
  4. Action results show as a short-lived (12s TTL) styled toast; the dangerous ON-arm transition is styled alert, not green.
Safety

An independent review traced every path to set_autonomous(True, …) and confirmed arming is reachable only through a genuine typed-yes human confirmation (fail-closed on every error path). The arm prompt shows mode + allowlist so an operator can see whether they’re arming paper or LIVE. Contract change from v1 (“strictly read-only”) is documented in the spec’s ## v2 section.

Design / testability

Action logic (toggle_autonomy, _guarded, build_help_screen, _visible_slice, _human_dt, _message_style, _confirm_arm_autonomy fail-closed) is pure/injectable and unit-tested; the curses loop + live network fetch stay thin I/O. --once remains a static, read-only snapshot.

Testing
  • +28 tests; full suite 1550 passed, ruff check clean.
  • keel tui --once verified against the real keel.db (human dates + keybinding footer).

Design: docs/superpowers/specs/2026-07-24-tui-dashboard-design.md (§ v2).

fix(cli): drop the ‘now=’ label from the keel tui header (#143)

Small cosmetic fix: the keel tui header showed keel · paper mode · now=<datetime>. Drops the now= label, keeping the human-readable clock:

keel · paper mode · 2026-07-24 06:08:13

Full suite 1551 passed, ruff clean.

fix(cli): drop the clock from the keel tui header (#144)

Follow-up to #143. Removes the live date/time from the keel tui header — it’s now just:

keel · paper mode

Event timestamps elsewhere (a position’s opened_at, autonomy lapse times) still render human-readably via _human_dt; only the header clock is gone. Full suite 1550 passed, ruff clean.

feat(cli): show short version in the keel tui header (#145)

The keel tui header now shows a short version tag:

keel v0.1 · paper mode

v<major>.<minor> derived once at import from the package version via the lightweight metadata reader (no git subprocess per repaint); falls back to v? for an unknown/unparseable version. Full suite 1557 passed, ruff clean.

feat(cli): live “available to buy” balance in keel tui (#146)

Stacks on #145 (branch is a descendant of feat/tui-version-header, so it merges cleanly after #145; until then the diff shows #145’s one-line change too).

What

The keel tui dashboard now shows the real account’s spendable quote-currency balance (e.g. USDC) available to fund buys, refreshed periodically so a deposit / sell / buy is reflected:

live account: 65.72 USDC available  (2026-07-24 06:20:11)

Reuses executor._fetch_available_quote — the exact live balance rail 13 funds a buy against — so the TUI and the rail never disagree.

How
  • AvailableBalance + pure _available_lines (an ok line, or a warn “unreadable” line). Rendered via build_screen’s new keyword-only available param — default None, so --once and every existing caller stay network-free.
  • run_live refreshes the balance on a slow cadence (30s, not every repaint), on r, and after f (a fetch is a “refresh everything” gesture — a deposit that lands alongside new candles shows up at once).
  • Fail-soft: any broker/network/credentials error becomes a warn line, never crashes the loop; None (no readable balance) is shown as “unreadable”, never a false $0.
Robustness (from independent review)
  • Bounded 10s broker timeout for this path (new optional _build_broker(timeout=) kwarg; default None → agent/executor path unchanged) so a hung get_accounts can’t freeze the TUI.
  • Balance fetch runs after the first paint, so startup is never a blank screen.
  • Error text truncated (defense-in-depth) before painting.
  • Labeled “live account: … available” (not “available to buy”) so it isn’t confused with paper buying power in paper mode.
Testing
  • +9 tests (pure _available_lines, build_screen placement with/without balance, _refresh_balance value/None/raising/truncation). Full suite 1566 passed, ruff clean. keel tui --once verified network-free.

Design: docs/superpowers/specs/2026-07-24-tui-dashboard-design.md (§ v3).

chore(release): bump version 0.1.0 → 0.2.0 (#147)

Version bump to 0.2.0 across the root distribution and all workspace members (keel-core, keel-broker-api, keel-broker-coinbase, keel-broker-fake) + uv.lock, in lockstep. The release.yml workflow asserts pyproject.toml already equals the requested version, so this PR is the prerequisite for cutting v0.2.0.

After merge, keel --version reports 0.2.0 and the TUI header reads keel v0.2 · <mode> mode.

⚠️ This is a software version bump, not a go-live

It does not change trading behavior:

  • The 5-trend turtles remain status=paper (mid paper-forward proving).
  • No live rules are armed (DCA disabled; SOL/LTC/LINK candidate).
  • Going live still requires the paper-forward to clear the n=100 promotion gate → a deliberate keel rules promote … --to live → a supervised confirm-mode run. None of that is part of this PR.
What 0.2.0 marks (headline features since 0.1.0)

Paper-mode fidelity + funded paper-forward enablement; keel status; the interactive keel tui (live read-only dashboard → browsable help, gated autonomy toggle, fetch, refresh, human-readable time, short-version header, and a live “available to buy” account-balance line).

Release steps after this merges (operator)
  1. Run release.yml (workflow_dispatch, version 0.2.0) → tags v0.2.0, stamps _build_info.py (DIRTY=False), builds wheels.
  2. On the machine: git fetch && git checkout v0.2.0 && uv sync.
  3. keel migrate (idempotent, schema-only, safe on the live DB).
  4. Rules are already seeded — do not re-seed. Verify with keel rules list / keel status.
  5. Reload the paper-forward launchd job only if its wrapper/paths changed.

Full suite 1566 passed, ruff clean.

fix(release): grant pull-requests:read so the notes step stops 403ing (#148)

The failure

The v0.2.0 release run failed at Compose release notes (run):

repos/.../commits/<sha>/pulls → 403 "Resource not accessible by integration"
… then: Get "repos/.../pulls/{"message":"Resource not accessible…}" : unsupported protocol scheme ""
Root cause

The step reads merged PRs via gh api .../commits/<sha>/pulls and .../pulls/<n>, which require pull-requests: read. The workflow only granted contents: write, so the default GITHUB_TOKEN was denied (403). The 403 error body then got written into the PR-number list and fed back into a URL, producing the unsupported protocol scheme crash.

Fix
  • Add pull-requests: read to permissions (the root cause).
  • Defense-in-depth: only append bare numeric PR numbers to the list, and skip any non-numeric entry when fetching — so a future API hiccup can’t crash the notes step the same way.

YAML validated locally. No code paths touched — workflow only.

⚠️ Recovery needed before re-running (the tag already exists)

The failed run’s Tag step already pushed v0.2.0 (at the #147 merge commit), but no Release was published (the notes step died first). The workflow’s own “tag already exists — immutable” guard will block a re-run until the orphan tag is deleted:

git push origin --delete v0.2.0     # remove the orphan tag (no Release was published)
git tag -d v0.2.0                    # local, if present

Then, after merging this PR, re-run the release workflow (workflow_dispatch, version 0.2.0).

v0.1.0

Voir cette version sur GitHub ↗

Built from f116dc5c948f. Version binds to this hash: keel --version reports keel 0.1.0+f116dc5c948f [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.1.0-py3-none-any.whl
keel --version

⚠️ 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.

What’s Changed

Other changes

Full Changelog: https://github.com/CodeGateSoftware/coinbase/commits/v0.1.0

Traduit d'après la révision anglaise 2026-08-20.1.