v0.21.0
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_qtysums open trancheqty.orders_qty(repo, product, mode)nets filled BUY minus SELL in the requested mode. It is deliberately notexecutor._held_position, which is live-only by design (#881).
doctor.ledger_drift_findingswarns 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_findingsfrom 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 oneget_balances()call and writesvenue_holding:<product> = {"total": str, "observed_at": int}for each product with an open tranche.- It reads
Balance.total, neveravailable(#667). - It places and cancels nothing.
- It makes no call when nothing is held.
- It reads
-
run_oncecalls 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_findingswarns 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:
InstrumentacceptsDecimal('Infinity')(it is> 0).- The fresh
get_instrumentanswer skipped_coerce_incremententirely. - The cached path let
Infinitythrough, andNaN > 0raises. quantize_downthen raisesInvalidOperation, which is anArithmeticError, not aValueError.
Fixed at both levels:
_coerce_incrementrequires a finite increment, and the fresh answer goes through it.- The spec-build catches in
place_bracketand_roll_stopnow includeArithmeticError. A finite increment far finer than the 28-digit context (1E-40) still raisesDivisionImpossible, so fixing the cause alone was not enough. In_roll_stopthe 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_sellsreadsexecutor.RESTING_STATUSES, so an unlinkedpartially_filledSELL 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 intest_doctor_fix_lines.pyscans backtick-quoted invocations. -
(d) Renamed
test_unmanaged_matches_on_product_not_on_rule_idtotest_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:
- Prerequisites: #799, #811 and #798.
reverse_dca.- Trims (
keel dca trim --preview, andprofit_takein 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 --sellswas armed at a TTY. The liveautonomy onflag 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_sellkind declaresExecution = Literal["preview"], soexecution: autodoes 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 fromrepo.get_open_positionsafterbook_exit, instead of from the pre-saleprotecting_remainderarithmetic. This mirrorsagent._handle_exits’s tail (keel/agent.py:950-987):- A true full close clears
position_rule:,open_stop:,open_target:andunbracketed:together. Before, onlyunbracketed:was cleared, so a staleopen_stopmade 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_tooandtest_887_a_short_filled_full_close_rewrites_the_retry_record_from_the_ledger. Step 2 item 5 records how each one fails first.
- A true full close clears
- P10 / P15 doctor findings. When there is nothing to report,
sleeve_rule_findingsreturns OK for both of its names andexit_watch_findings({})returns one OKsleeve.exit_watch. This matches P3’sledger.driftsentinel, so the exact-set test attests/commands/test_doctor.py:603keeps 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_orderbeforeconfirm_fnis called, and the spread gate only applies to BUYs. After a yes, the only way the SELL can fail is aPlaceResult(success=False)from the broker. Thefailedreason no longer claims a bracket was cancelled for a tranche that never had one. - R35: P18 exit codes (new).
--confirmexits 0 only when the decision isplaced.declinedandfailedboth exit 1 throughclick.ClickException(f"{decision}: {reason}"), which is the convention acrosskeel/commands(_require_interactive_confirmation,orders cancel). Codes 4 and 5 are reserved for the day-stamping wrapper contracts inagent.py.--confirmrequires 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 callsbroker.place_order;RUN_ORDER_CALLERS, the four functions that call_run_order;SECOND_LEVEL_CALLERS, every function underkeelthat callsexecutor.execute/place_bracket/scale_out/roll_stop_to/roll_to_break_even/trail_stop_atr;WEB_FORBIDDEN_NAMES, together with a check thatexecutor.reducedoes not appear inkeel/weborkeel/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 --sellsat a TTY. P1 adds no sell path and no new caller of_run_order,place_orderor anyexecutor.*order entry point. The invariants module added here pins the current inventory.executor.reducedoes not exist. - S2: v1 execution is preview-only. Unaffected: no
sleeve_sellrule class exists yet. - S3: rails on sells. No rail is changed. The bracket still goes through
guards.checkunchanged inside_run_order. - S4: the browser never increases capability. Pinned by
test_the_browser_and_mcp_name_no_sell_side_operation.keel/webandkeel/mcpare 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 withinitial_stop=95.00and a retry record. Without the fix it fails withInvalidOperationout ofrun_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, baremypy.
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 usesrisk_pct=0.0001and 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, orpaperon a paper profile, mirroringagent.py’srule_status) whose product AND kind match the tranche’s(product_id, rule_name). It does not userule_id, which is NULL before #803. Product alone is not enough (#897):_handle_exitsfinds the owner byr.name == rule_name, so a livedcarule 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), orno rule.position.unprotected(WARN): an open tranche withinitial_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
pendinglive 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,
productsis populated in--json(#642). - Both are wired into
gather_findingsright afterledger.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 --sellsat a TTY. This PR adds no caller of_run_order,broker.place_orderor anyexecutor.*placement function.tests/execution/test_sell_side_invariants.pypasses 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/orkeel/mcp/module is touched. The MCP doctor tool inherits two read-only findings throughgather_findings.
A doctor finding places nothing.
Acceptance (#811)
- A demoted rule with an open tranche produces
position.unmanagedWARN 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.unprotectedWARN. 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
--jsonwithproducts == ["PAXG-USD"]. - The
~/keel/keel-live.dbbullet is replaced by a fixture of tranche 3’s exact row (this plan may not touch~/keel). The operator runskeel doctoron 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 plusrender_jsonproducts, 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, baremypy.
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_assetnets per product. A product whose filled BUY qty minus SELL qty is<= 0now 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 bytest_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_positionis in the new modulekeel/commands/positions_close.py. It writes oneordersrow withmode=live,SELL,order_type=out_of_band,status=filledandconfirmation=operator_declared. The row’sqtyandfilled_quantityboth equal the tranche’s ledger qty. It also carries the operator’s price and fee and the tranche’srule_id. The service then books that one tranche throughrecord_closed_trade, withis_dcataken from the tranche’s ownrule_name, and closes it. When it closes the product’s last tranche, it also retiresposition_rule:,open_stop:,open_target:andunbracketed:. It refuses the following before anything is written:- an unknown or already-closed id;
- a price that is
<= 0or not finite; - a fee that is
< 0or not finite; - a paper profile.
- Task 4.2 (R30):
keel positions close <id> --price P [--fee F]is a newpositionsgroup with this one verb.positions_close_gateruns the refusals first, then the typedyesgate, then the write. It has one new capability row. - Doctor. The fix lines on
ledger.venue_driftandposition.unmanagednow namekeel positions close <id> --price P. The #798-pending allowlist intest_doctor_fix_linesis gone.ledger.unbooked_exitno longer reads anout_of_bandSELL 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_brokerseam to raise and count calls, and they assert zero calls.test_sell_side_invariants.py’sPLACEMENT_CALLERS,RUN_ORDER_CALLERSandSECOND_LEVEL_CALLERSpins 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.checkhere, 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_positionjoinsWEB_FORBIDDEN_NAMES.- The gate function calls
close_declared_position, so the web effect scan intests/web/test_server.pyderives it as forbidden automatically. closejoins the MCP write vocabulary (tests/mcp/test_readonly.py,docs/mcp-server.md).- I planted a call to
close_declared_positioninkeel/web/api.pyas 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_economicsread the order status exactly once, in the same instant asplace_order.- A market IOC that is read back that quickly has not settled at Coinbase yet. The answer is
PENDING/OPENwithfilled_size0. _record_observed_fill_quantitydropped the zero without logging anything.- The row had already been written as
filled(_initial_status), andreconcileonly pollsRESTING_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
- 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_STATUSESis pinned equal to reconcile’s_FILLED ∪ _DEAD.filled_quantityis recorded only from that terminal answer. If no terminal answer comes, it stays NULL, never guessed, andexecutor.fill_quantity_unobservedlogs a WARNING. - Tranche booking.
agent._open_tranchebooksfilled_quantitywhen the venue gave one. Otherwise it books the orderedqtyand logsagent.tranche_qty_unobservedat WARNING. The warning fires for live rows only. - 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/EXPIREDwith fills) instead offilled < qty. A quote-sized BUY always hasfilled < qty, so the old check fired on every entry. - Consistency. These now all read
filled_quantity or qty:sleeve.orders_qty,executor._held_position, andagent._held_position. R33 (guards._open_exposure_by_asset) already did. A new pin shows that closing every tranche withkeel positions closenets the product to exactly zero on every reader, and that R-f admits the close. - Paper and sim. Neither is affected.
PaperTrader,strategy/backtestandsim/accountcharge the fee on top of the notional, so theirqtyis the quantity delivered.sim/accumulation_policy(#831 research) already models fee-in-quote and never writesorders. The new tranche WARNING does not fire formode='paper'. - Spend and exposure counts are unchanged.
guards._order_notionaladds the fee to a BUY that has an observedfilled_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 orderedqty × pricegave before. The fee is not added on theqtyfallback, where the quote already contains it, nor on a SELL. - Price and fee come only from the terminal read.
actual_fillandfeeare upgraded only from the same terminal observation asfilled_quantity. Otherwise the estimates stand. - doctor
ledger.venue_drift. If a product’s gap is above the tolerance but no larger thantolerance + Σ(entry_fee / entry_fill)over its open tranches, its clause names #900 and its fix line no longer sends the operator tokeel 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.partialno longer judges a filled market BUY byfilled < qty, because thatqtyis a quote estimate.- The #900 fee band counts only tranches backed by a live BUY with NULL
filled_quantityand an equalqty. - The fee figure in the clause is quantized up to the base increment.
fill_quantity_unobserveduses 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_rowskeeps the ledger’s FIFO order and filters nothing byrule_name.- Cost basis includes the entry fee, prorated by
qty / (qty + realized_qty)(spec §3.3, Q4). SellCostsrefuses fee plus slippage outside[0, 1).Reductionrefuses a non-positive qty or price, and an emptyreason.
Action.REDUCEandRule.reduce_signal(holding, candles_by_tf, costs) -> Noneby default (R6). A test checks every kind inRULE_REGISTRYreturnsNoneover a real holding.promotion.SLEEVE_SELLandpromotion.RECOGNISED_CLASSES(R10). The conformance test now readsRECOGNISED_CLASSES.SLEEVE_SELLis deliberately not in_CLASS_FLOORS.streak.book_exit(is_dca=None)derives each leg’s flag from its own tranche’srule_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). Itsqtyis pinned equal toledger_qtyover the same rows.- Both
Holdingandholding_ofdocument #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_qtyandledger.venue_driftalready cover that gap.
- Both
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_intentnow raisesActionNotExecutablefor anything outsideEXECUTABLE_ACTIONS = {ENTER, EXIT}, before it reads anything.executeandagent._paper_enterboth 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 newordersrow on the live path; - on the paper path, zero calls to
guards.checkandPaperTrader.on_signal(spies count them), and no paper row.
- no
Safety invariants
- S1: no new caller of
_run_orderorbroker.place_order, and no new second-level caller.tests/execution/test_sell_side_invariants.pyis unchanged and green.executor.reducedoes not exist yet.REDUCEis refused byexecuteand the paper path (above). - S2: no
sleeve_sellrule kind exists yet.reduce_signalreturnsNoneon every registered rule, so nothing here runs on the live cycle. - S3: unchanged. No rail is touched.
- S4: unchanged. No
keel/web/orkeel/mcp/module is touched.
Verification
- Full
uv run pytest -q: 7291 passed, 3 skipped. uv run mypy(bare) is clean, and so areruff check keel testsandruff format --check.- Bare
ruff checkreports one pre-existing E501 indocs/experiments/2026-09-02-equities-cost-fidelity.py, which is onmainand untouched here.
- Bare
- 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.pyandsleeve.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
- Added the ENTER/EXIT-only chokepoint in
executor._build_intent, with its catches inexecuteand_paper_enter. The plan does not have it, but without itAction.REDUCEwould be executable, as a full exit, by an existing path. test_every_registered_rule_proposes_no_reduction_by_defaultiterates the wholeRULE_REGISTRY(six kinds), not the plan’s four hard-coded kinds.- Extra tests beyond the plan:
from_rowsorder and decoding,unrealised, the emptyReduction.reason, theSellCostsboundary at exactly 1, a partialis_dca=Nonesale that stops inside the DCA tranche, and an emptyholding_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_VERSION21 → 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, runkeel --db <db> migrate(printsschema 21 -> 22). Until that runs, the read-onlykeel dca proposalsverbs refuse the database with the stale-schema message. They never migrate it themselves.
What
- Task 6.1: v22 migration (
keel/data/db.py). Thesell_proposalsDDL, with the plan’s 21 columns and the(product_id, ts)index that R14’s per-day read uses._migrate_v22_sell_proposalsis a documented no-op in the v17/v19 pattern:IF NOT EXISTScreates the table before the version loop runs. Also in this task:_MIGRATIONS[22]andSCHEMA_VERSION = 22.- Schema-version pins:
- the eight
== db.SCHEMA_VERSION == 21pins intest_migrations.pyrelax todb.SCHEMA_VERSION; test_trade_outcomes.py’s pin relaxes and is renamedtest_schema_is_at_the_current_version;test_db.py’s deliberate tripwire is bumped totest_schema_version_is_22.
- the eight
- 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> migrateCLI. It then asserts:- every pre-existing table is unchanged, compared row by row;
sell_proposalsis 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_proposalandupdate_sell_proposaleach write the row and its audit event in onewrite_transaction. The events aresell_proposal_recordedandsell_proposal_updated, in the chain’s closed<store>_<verb>vocabulary. They are added toEVENT_STORESunder the storesell_proposals.get_sell_proposalandget_sell_proposalsreturn rows newest first. They filter by product, rule andsince_ts, and take a limit. Money comes back asDecimalandtrigger/railsasdict. A pre-v22 database reads as[]orNone, using thetable_presentidiom.
- Task 6.3:
keel dca proposals listandkeel dca proposals show <id>(keel/commands/dca.py, and the newkeel/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_lineandrender_proposalare 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.
- Both verbs are read-only. They always open the database through
Safety invariants
- S1 (unchanged): no new sell reaches the venue without
keel autonomy on --sellsat a TTY. This PR adds no placement path.PLACEMENT_CALLERS,RUN_ORDER_CALLERSandSECOND_LEVEL_CALLERSintests/execution/test_sell_side_invariants.pyare 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_proposalandupdate_sell_proposaljoinWEB_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 fromnow. The table has noupdated_at, and an update event is a statement that something changed at that moment. Cost if wrong: an event’stswould differ from a caller-chosen time by seconds.- The chain test reads events through
audit.read_events, notrepo.audit_chain().events. The plan’s test sketch used the latter, butChainStateexposes no event list. The plan said to fall back to the readertest_audit_chain.pyuses, 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 constantsFALLBACK_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):ReduceResultandreduce, 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, runsguards.checkunmodified, builds the spec, callspreview_order, and records onesell_proposalsrow. It never cancels, never writes anordersrow and never places. A preview failure is recorded on the fallback fee; it is never raised.
Safety invariants
- S1:
reducecalls none of_run_order,place_order,insert_order,update_order,_clear_resting_bracket,_cancel_at_exchange,cancel_orderorplace_bracket.PLACEMENT_CALLERS,RUN_ORDER_CALLERSandSECOND_LEVEL_CALLERSare 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:
previewis the only execution value.reducehas 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)
Reductionrefuses a non-finiteqtyorexpected_pricewithValueError. Before this,Infinitypassed andNaNraisedInvalidOperation. - (b)
insert_sell_proposalrefuses a key that is not a column, includingid.- Ruling: the check sits in the repository method, not the writer. Its sibling
update_sell_proposalalready refuses unknown keys, and the table reads NULL as “not recorded”, so a misspelt figure would otherwise be stored silently as missing.insert_orderdrops unknown keys, but it is not this table’s sibling.
- Ruling: the check sits in the repository method, not the writer. Its sibling
- (c) The preview’s sized quantity never exceeds the holding.
- It is capped at
holding.qty, then clamped to the venue’sBalance.total. Abelow_one_incrementveto 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.
- Every filled live BUY is a candidate owner. It is matched on its booked size (
positionshas no link to its entry order, so this is a match on size and time, not a stored link.
- It is capped at
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 atpaper+livein a live cycle andpaperonly 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 ofrun_once. - Task 8.2 —
agent._handle_reductions(...)andLoopResult.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 loserssuperseded; the sleeve caps (sleeve_refusal) refuse before any intent and recordvetoedwithrails.sleeve; thenexecutor.reduce(preview-only), offline with no broker on a paper cycle (R18). - Task 8.3 —
sleeve.proposal(execution,info) inkeel_core.notifications.EVENTS, derived byevents_from_state(..., sleeve_proposals=result.reduce_results)(R19).previewandvetoednotify;supersededdoes 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_qtyon the row,ReduceResult.total_qtyon the result, and “sellover legs” in the notification. The row’s qtyis 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 --sellsat a TTY. A livereverse_dcaonly proposes. Its one path is_handle_reductions→executor.reduce(P7/P8), which is preview-only.RUN_ORDER_CALLERS,PLACEMENT_CALLERS,SECOND_LEVEL_CALLERSandREDUCE_CALLERSare 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.
ReverseDcadeclaresexecution: ExecutionwithExecution = Literal["preview"], as a plain assignment so thatrules add’sLiteralvalidation sees it. Its constructor refuses anything else.test_every_sleeve_sell_kind_is_preview_onlyruns 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 (sorules addand_build_rulework), but it is excluded fromrules seed, the wizard andresearch.tuningthroughagent.seedable_kinds()(R20).rules seed --kinds reverse_dcais refused by name. - S3: unchanged. The same
guards.checkruns on every REDUCE intent. - S4: unchanged. No
keel/web/orkeel/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_dayshigh; floor_qty;- gross sized from the net target as
target / (1 - fee - slippage), at theSellCoststhe caller resolved; - no carry-forward, and
last_rejectionnames the gate.
Rail-2 slicing, the same-day-DCA exclusion,
min_hold_daysand one proposal per day stay in the pipeline (R11–R14). - epoch-aligned cadence, the same as
-
agent.RULE_REGISTRY["reverse_dca"]andagent.seedable_kinds(). The callersrules seed,setup.seed_rule_libraryandresearch.tuning.SEARCH_SPACESswitch to it. The last one runs at import time and would otherwise raiseTypeError. -
tests/strategy/rule_conformance.py:- a new
ReductionConformanceTestsmixin, 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.
- a new
-
Review Focus 5 (Task 9.3):
test_a_sleeve_sell_rule_does_not_count_as_managing_a_positionruns end to end throughgather_findingson a live and a paper profile, each with a positive control. No filter was added (R37, below).all_rulesstays 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_refusalreadssell_proposals, so it needs no new state and the notification layer still writes exactly one key.ReduceResult.repeats_previousis set byagent._flag_repeatand skipped byevents_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
vetoedproposal only when its kind or reason differs from the product’s previous non-superseded proposal. The reason israils.sleeveplus the vetoing rail names (the text before the colon, because the details carry live numbers). Apreviewalways notifies. Cost if wrong: a standing veto is announced once per change of reason, not daily;keel dca proposals liststill shows every row. - R37. Task 9.3 adds no class filter. Since #897,
position.unmanagedkeys 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_idalone readsseedable_kinds(), and the tests useminimal_params. Cost if wrong: a future sleeve kind is absent fromSEARCH_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)
DistributionRowplusdistribution_rows(repo, config, now_ts): one row perreverse_dcarule that this profile’s cycle runs (agent._sleeve_rules, R16).next_cadence_tsis the smallest epoch-aligned dayd >= todaywithd % 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 tod. 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, usingrecord_proposal’s andexecutor.reduce’s definitions. dca_collisionreads the pipeline’ssame_day_dcarefusal ahead of time.render_distributionproduces 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 byPRAGMA data_version, a per-table row count, and a_build_brokerspy 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,OKwhen there is nothing to report (#888). It is wired intogather_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 xthe 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_byfield andLoopResult.sleeve_only_productscarry the distinction.
Rulings (recorded in the plan)
- R39:
gatesstop 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, andlegsgives the day count. This matches thesleeve.proposalnotification’stotal_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
executorplacement path and builds no broker.tests/execution/test_sell_side_invariants.pyis untouched and green, withRUN_ORDER_CALLERS,PLACEMENT_CALLERSandSECOND_LEVEL_CALLERSunchanged. - S2: unchanged.
reverse_dcais stillExecution = 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/orkeel/mcp/module was touched. The command is read-only and not gated. Doctor’sgather_findings, which is shared with MCP, stays pinned read-only by its change-counter test, and the new wiring test also assertstotal_changesis 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 -qpassed: 7570 passed, 3 skipped.ruff check keel tests,ruff format --checkand baremypyare 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
dcarow reports only its own remaining lots: the qty still held, and the cost net of the basis sold from those lots. - Each
reverse_dcarow hasbuys=0,qty=0andcost_usd=0. It carries the newDcaSleevecolumns:distributions,units_sold,distributed_usd(net),sell_feesand a FIFO-exactrealised_pnl. - New parameter
max_per_order_usd: Decimal | None = None, which means no cap.keel simulatepasses itconfig.caps.max_per_order_usd, the same cap the account sim andguards.checkread.
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).
- It refuses a sale larger than the lot (or of zero, or with no lot) with a
portfolio_sim._process_reductionsmakes at most one sale per asset per UTC day and books it as aDcaSellinSimResult.dca_sells.dca_sleevetotals 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 inguards.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_dayson the lots actually consumed, andcooldown_days;sleeve.slice_qtyfor 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 --sellsat a TTY. It is unchanged: the sim has no broker and places no orders.RUN_ORDER_CALLERS,PLACEMENT_CALLERSandSECOND_LEVEL_CALLERSintests/execution/test_sell_side_invariants.pyare untouched and green. - S2: v1 execution is preview-only. Unchanged; no rule class or
Executionliteral 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 withguards.check’s rail 2. - S4: the browser never gains capability. Unchanged; nothing under
keel/web/orkeel/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 onreduce_signal. paper → live also needsSLEEVE_SELL_MIN_PAPER_DAYS = 60sincepromoted_at, at least one reviewed paper proposal, and, beside alivedcaon the product,--allow-concurrent-dca. Every failing reason is reported at once. A NULLpromoted_atrefuses rather than reading as the epoch. - Task 12.2 — routing in
attempt_promotionplus the R21 adapter_reduction_as_detect. A sleeve-sell rule is never backtested for a trade floor; the test stubsbacktest.backtestto raise. The adapter mapsentry = expected_price,stop = 0,target = qtyover 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 promotegains--allow-concurrent-dca.--forceis refused for a sleeve-sell rule (spec §3.7, R45). - Task 12.3 — proposal replay in
keel rules backtestfor 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 ownsleeve_refusal(same-day DCA through eachdcarule’s owndetect,min_hold_days, cooldown) andslice_qty. Each row gets a FIFO realised P&L, and the terminal value is shown with and without the rule. The headline is atconfig.fees.taker_pct. A second fee line appears only with--fee-sensitivity-pct(no default). A scan asserts\b0\.009\bappears nowhere inkeel/(R29). - The
rules add --kind reverse_dcahint 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]viaclick.confirmat 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 byPRAGMA data_version). The write is chained assell_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 --sellsat a TTY. Unchanged. TheRUN_ORDER_CALLERS/PLACEMENT_CALLERS/SECOND_LEVEL_CALLERSpins are untouched, and nothing in this PR places or cancels. A sleeve-sell rule promoted tolivestill only proposes (S2). - S2: v1 execution is preview-only. Unchanged.
--allow-concurrent-dcaweakens nothing, because alivesell 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.
reviewlives inkeel/commands/dca.py, and nokeel/web/orkeel/mcp/module namesupdate_sell_proposal.proposals reviewis 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)
LotRowpluslots_view(repo, config, product_id=None): one row per openpositionstranche, throughsleeve.holding_of. It covers every rule’s tranches (R8), oldest first. That is the orderbook_exitconsumes, so on PAXG turtle tranche 3 is the first row.- Each row carries
entry_fee_shareandcostfromLot, the mark (the latest cached daily close),unrealised, andrealised_if_sold. The last is priced byrecord_proposal’s definitions atsleeve.sell_costs(the fallback fee plus per-product slippage). - With no mark, the mark and both P&L figures are
None, never zero. render_lotsprints 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,BandsReportandbands_view(repo, config). Each target asset’s weight is shown against its renormalisedtarget_weightsentry, with the bandmax(15% x target, 1.5%). A weight is onlyoverorunderstrictly 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_rebalancerule, 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_brokerspy that raises. Each runs for both views, at a TTY and off one. - A missing
--previewis a usage error. It is named first, because the option is eager. A missing--viewis a usage error.--view gainis a usage error until P14 (R24). - A case-colliding
target_weights(#848) is a clean error, not a traceback. DISTRIBUTE_PREVIEW_FOOTERis renamedPREVIEW_FOOTERand 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_ruleaction inkeel/commands/setup.pynow reads the rule’s registered class over a read-only connection. For asleeve_sellrule at any status it returns a refusal that nameskeel rules promote <id>and--allow-concurrent-dca. It starts no job and writes nothing, whichPRAGMA data_versionpins. 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=Nonefallback in the sleeve gate is stricter (R54). It now counts any non-disableddcaon the product, labellednon-disabledon the gate line, instead of readinglive. 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_weightsassets’ holdings, renormalised. Untargeted holdings are named, not weighed.BandRowgainsvalue_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_takeproposes only throughreduce_signal→_handle_reductions→executor.reducein preview.RUN_ORDER_CALLERS,PLACEMENT_CALLERSandSECOND_LEVEL_CALLERSare unchanged, andtests/execution/test_sell_side_invariants.pypasses as it stands. - S2: preview-only.
profit_takedeclaresExecution = Literal["preview"], and its constructor refuses anything else. The registry-wide test now also assertsprofit_takeis in the sleeve-sell set, so it is covered and not only present. - S3: unchanged. No rail is touched. The trim reaches
guards.checkthroughexecutor.reduce, like every REDUCE intent. - S4: unchanged. No
keel/web/orkeel/mcp/module names anything new. The webpromote_rulerefusesprofit_takethrough the class it is registered under (R53). The existing refusal test is now parametrized over both sleeve kinds. - No
profit_takepromotion is part of this plan (spec §4: “noprofit_takerule 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), wherevwaeincludes 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.
- trigger:
last_rejectionnames the gate that stopped it:gain,fee_gate,nothing_heldorno_daily_candles.- The drift trigger is not a param.
cooldown_daysandmin_hold_daysare read by the pipeline (R15, R12). - Registered in
agent.RULE_REGISTRYand 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_takethat never fires is refused (R44). Paper → live needs 60 days and a reviewed proposal. Apromotion_classmutant turns all three tests red, and the 4 web-refusal cases too. - End to end with the real builder: a stored row proposes one
previewtrim; the next day’s trim isvetoedby its owncooldown_days; a decline logsagent.reduction_declinedwith 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 trimtakes its figures from theReduction;below fee gatetakes them fromlast_rejection;below triggerprints no sale;no cached daily closegives no verdict.
legs comes from sleeve.slice_qty. The report says every run that the pipeline’s caps are not applied (R57).
CLI changes:
--viewdefaults togain.--productnarrows the gain and lots views.--gain-pctand--trim-pctare 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 pureclassifythat places a product atclear,near,breachedorinsufficient_historyagainst two arms:drawdown: 35% under the 200-day high;sma: three closes in a row under the 200-day SMA.
nearhas hysteresis: a product enters withinwarn_pctand leaves beyond 2×warn_pct. The default levels are module constants taken from spec §7 (R26).monitor_ruleandmonitor_paramsread asleeve_exitrule’s overrides. P16 registers that kind. -
Task 15.2:
agent._watch_sleeve_exits, the last step ofrun_once.- It watches every product that has an open tranche with no resting bracket. It reads the
positionsledger, never theunbracketed:retry record, which was #811’s silent channel. - It records
sleeve_exit:<product>and returns level changes only, onLoopResult.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.backfilloversleeve_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 writeslast_feed_tsand never joins the products that equity is marked over.
- It watches every product that has an open tranche with no resting bracket. It reads the
-
Task 15.3.
- A new
sleeve.exit_watchnotification (WARN, execution), one per transition. The wording follows the level, and a return toclearreads 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.
- A new
-
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 badprofit_takerow crashed the whole gain view (aValueErrortraceback). -
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 readsinsufficient_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_buycovers the per-product wrap, andtest_a_watch_that_raises_before_any_product_costs_nothingcovers the outer wrap inrun_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_tsis 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 bothONE_HOURandONE_DAY, and the watch still polls onlyONE_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_signalcalls the monitor’s ownkeel.execution.sleeve_exit.classifyat the rule’s params. It returns aReductionfor the whole holding only onbreached.near,clearandinsufficient_historydecline, andlast_rejectionnames the level.- Params:
dd_pct35,lookback_days200,sma_period200,confirm_days3,warn_pct5,arms("drawdown", "sma"). These are R26’s spec constants, untuned.
-
Registration. It is in
agent.RULE_REGISTRYand excluded fromrules seedand the wizard by class (R20/R38/R61).minimal_paramsneeds 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_sellpromotion 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_classmutant and a registry-removal mutant each turn these red. - conformance (
-
R13 is exercised end to end. A tranche bought 2 days ago is still proposed, and a mutant that empties
MIN_HOLD_EXEMPT_KINDSturns that red. The same-day-DCA exclusion still applies: a breach on a buy day is recordedvetoed(same_day_dca), and the BUY still goes out. -
R36. The next day’s identical veto carries
repeats_previous, andsleeve.proposalskips it. -
Rail 2. A whole sleeve over
max_per_order_usdbecomes 3 legs in the proposal row. -
R69 is retired (R72).
monitor_rule/monitor_paramsmove to the rule module and selectisinstance(rule, SleeveExit), using the constructed rule’s validated params. A rule merely namedsleeve_exitno 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:
armsthat is not a list of distinct known arm names. A bare string ("sma") is refused by name.agent.build_rule_from_paramsno longer splits a JSON string into letters.- non-positive or non-int
lookback_days,sma_periodorconfirm_days,boolincluded; dd_pctoutside(0, 100)andwarn_pctoutside[0, 100), and either one if it is not finite (NaN, sNaN, ±Infinity).
rules addwrites nothing for any of these. -
(b) Per-product wrap in
exit_watch_view(R74). A watched product whosesleeve_exit:record is not a mapping, has a non-string level or a non-intobserved_at, or whose read raises, prints oneSKIPPED <product>: ...line.keel dca exit --previewstill prints every other product and exits 0. -
(c) Severity (R75).
sleeve.exit_watchis WARN only fornearandbreached. “Now watched”, “recovered” andinsufficient_historyare INFO.- keel-core fixed severity per key, so
notification_eventgains a keyword-onlyseveritythat may lower an occurrence below its key’s declared ceiling and never raise it. An unknown severity is aValueError. - Delivery routes on the key, not on severity. A test pins a lowered event through
send_event.
- keel-core fixed severity per key, so
-
(d) Delivery failure: HELD (R76). There is no retry or outbox pattern to reuse.
send_eventmakes one attempt by design, andsleeve.proposalbehaves 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.pypin to exactly one. That is the operator’s design call. - The level itself is never lost:
keel doctorWARNs onnear/breachedevery 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.paperstill rides onto the row; the sleeve caps still judge it against the cycle’s own DCA (dca_fires_todaykeeps the CYCLE’s mode); a LIVE rule’s proposal is still quoted at the venue, unchanged. - R79:
executor.reducenever writes the trade-scope refutation. A refusal on a sell preview stays on the proposal row (rails.preview_error, WARNexecutor.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 whatreconcile’s own docstring says a broker rejection writes; - the
unbracketed:retry record is armed (not cleared), soreconcile_unbracketed_positionsre-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_refusednames 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 saysretry_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_proposalsschema v22,executor.reducein preview, reductions running last in the cycle), #918-#928 (reverse_dca,keel dca distribute --preview, the sim reverse path, the CLI-onlysleeve_sellpromotion 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).