Skip to content

keel v0.21.0

Latest

Choose a tag to compare

@github-actions github-actions released this 30 Sep 10:51
· 30 commits to main since this release
9b1db87

Built from 9b1db87. 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)

# PR Closes Size Schema
P1 fix(execution): a bracket leg that throws after a filled entry downgrades to open-unbracketed refs #799 S none
P2 feat(doctor): position.unmanaged and position.unprotected #811 M none
P3 feat(doctor): ledger.drift and ledger.venue_drift #799 M none
P4 feat(positions): keel positions close <id> #798 M none
P5 feat(strategy): sell-side contracts: Holding, Reduction, Action.REDUCE, reduce_signal, book_exit(is_dca=None) — M none
P6 feat(data): the sell_proposals table and keel dca proposals list/show — M v22
P7 feat(execution): executor.reduce in preview — M none
P8 feat(agent): _handle_reductions — M none
P9 feat(rules): reverse_dca, preview-only — M none
P10 feat(dca): keel dca distribute --preview and its doctor findings — S none
P11 feat(sim): the reverse path — M none
P12 feat(promotion): the sleeve_sell gate, proposal replay and proposals review — M none
P13 feat(dca): keel dca trim --preview --view lots and --view bands — M none
P14 feat(rules): profit_take in preview, and --view gain — M none
P15 feat(execution): the sleeve exit monitor — M none
P16 feat(rules): sleeve_exit, preview-only #857 S none
P17 feat(autonomy): keel autonomy on --sells — M v23
P18 feat(dca): --confirm <proposal-id> behind the sells window and a typed yes — M none

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:

Stage Retry record unbracketed: orders row Log
Before the bracket row exists (preview raised; spec build already handled) written, same as a veto none WARNING executor.bracket_not_placed
After it exists (place_order raised, TradeScopeDenied included) not written, and a leftover one is cleared stays pending CRITICAL 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)
Rail On a reduction Test
1 allowlist vetoes ..._vetoes_the_reduction_before_the_venue_quotes[1-halal_allowlist]
2 per-order cap sliced, not vetoed; a control shows the unsliced order is vetoed test_rail_2_is_sliced_not_vetoed_and_the_legs_are_recorded
7 min move not evaluated (stop is None) test_rails_9_and_10_are_satisfied_by_construction
9 stop widening not evaluated: no stop or protective_stop, even with a high open_stop same
10 sell-only-on-rule satisfied: rule_kind = reduction.reason, never empty same
12 kill switch / stale feed vetoes [12-kill_switch], [12-stale_data]
18 settlement currency vetoes (BTC-EUR) [18-settlement_currency]
19 spot shape vetoes (BTC-PERP-USD) [19-spot_instrument]
21 base balance vetoes when the venue affirms 0 test_rail_21_vetoes_when_the_venue_affirms_a_zero_holding
3, 4, 5, 6, 8, 11, 13, 14, 16, 17, 20, 22 do not bite: all are is_buy-gated test_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).