Skip to content

keel v0.22.0

Latest

Choose a tag to compare

@github-actions github-actions released this 06 Nov 21:55
· 3 commits to main since this release
90cb606

Built from 90cb606. Version binds to this hash:
keel --version reports keel 0.22.0+90cb606ff7fe [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.22.0-py3-none-any.whl
keel versions

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

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

Configure

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

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

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

Other changes

console: Pulse home view with HALT; Status, Activity and Gates views retired (#13 PR1) (#956)

Closes #951

New /api/pulse and pulseView are now ROUTES[0] and the / default. Pulse shows:

  • kill switch, autonomy, lapses, rail 11, drawdown, rail 17 and market session
  • today's cycle counts (signals, blocked, entered, exited, errors)
  • "why keel didn't trade"
  • last cycle time
  • next DCA cadence day for each live dca rule
  • open positions, with the missing-bracket count
  • freshness, subscriptions, and today's cycles with their detail

The HALT button posts to the existing POST /api/gates/halt with the gate token that /api/pulse carries, after one native confirm. Once the kill switch is engaged, the button is replaced by a HALTED line naming keel resume; there is no resume in the browser. /status, /activity, /gates and their API routes are removed in lockstep. The nav is now: Pulse, Setup, Orders, Positions, Balances, Timeline, Insights, Research, Rules, Venues, Plans, plus Docs. Old /status, /activity and /gates links now open Pulse.

This is PR 1 of 4 of the plan, which this PR also copies to docs/superpowers/plans/2026-10-10-console-four-views.md.

Commits

  • dca: is_cadence_day and next_cadence_day_ts. Dca.detect now calls is_cadence_day, so the console and the engine share one cadence expression.
  • commands/pulse.py: a pure service with no clock and no click; it builds the PulseReport from the existing reports.
  • payload.pulse_payload, which reuses the status helpers (factored, not copied).
  • /api/pulse replaces /api/status, /api/activity and /api/gates. The gate token is on /api/pulse only, and that is pinned.
  • Client:
    • pulseView and haltControl, with HALT_CONFIRM.
    • The gate token is held in actions.js and sent by api.js.
    • Route tables, manifest start_url, CSS.
  • Docs: the runbook console section, the README tree line and the plan copy. The runbook's view-name pin now reads CLIENT_ROUTES.
  • A fix found by the browser check: the halt POST sends no body (see below).

Test evidence

  • Baseline at 9b1db87: 8161 passed, 3 skipped.
  • Head 0678662: 8184 passed, 3 skipped.
  • ruff check keel tests packages: All checks passed.
  • ruff format --check .: 549 files already formatted.
  • mypy (bare): no issues in 508 source files.
  • keel --version: keel 0.21.0+0678662ed60e [checkout].
  • node --check passes on main.js, render.js, api.js, actions.js and sw.js.
  • TDD: every task's new tests were run red first, for the stated reason, before the code.
  • Mutants against test_pulse_view.py were each confirmed applied before running, then restored. All of these were killed:
    • no confirm;
    • a second button;
    • a resume route;
    • the gate token held in api.js;
    • halt before confirm;
    • Content-Type sent outside the body guard;
    • a view missing from the runbook.

Browser check

Run in real Chromium (Playwright) against keel serve started from the worktree on ports 8791 and 8792. It used a scratch copy of the checkout's paper config.yaml, keel.db and logs/keel.log, plus two synthetic cycles for today so the cycles table and the why-not list have rows. The service worker was unregistered and its caches deleted before the first load.

…truncated — full description in #956.

fix(deps): bump pyjwt and urllib3 to clear pip-audit (#957)

The security workflow's pip-audit job is red on every PR (first seen on #956, run 38087415968). It reports five advisories in the locked dependency set:

Package Locked Advisories Fixed in
pyjwt 2.14.0 PYSEC-2026-4141, PYSEC-2026-4183 2.15.0
urllib3 2.7.0 PYSEC-2026-4175, -4176, -4177 2.8.0

This PR changes uv.lock only, via uv lock --upgrade-package pyjwt --upgrade-package urllib3. Result: pyjwt 2.14.0 → 2.15.1, urllib3 2.7.0 → 2.8.0. No other package changes.

Verification

  • I reran the workflow's exact export (uv export --frozen --all-extras --no-hashes, excluding the eight keel distributions), then uvx pip-audit --requirement. Result: No known vulnerabilities found.
  • I ran uv sync --frozen --all-extras in a clean worktree, then pytest -q with the new versions installed (jwt 2.15.1, urllib3 2.8.0): 8161 passed, 3 skipped.

Deployment note

~/keel runs from release wheels, so this reaches the trading deployment only with the next release. pyjwt is what signs the Coinbase CDP JWT, and urllib3 sits under requests.

console: Holdings view replaces Positions and Orders (#13 PR2) (#959)

Closes #952. PR2 of four (#13 console slimming): Holdings replaces the Positions and Orders views.

What changed

  • keel/commands/holdings.py (new): open tranches with per-tranche cost and the entry order joined to each tranche. positions stores no order id, so the join uses the fill the tranche copied from its order (see rulings). When no order matches, the tranche says "entry order not identified", and its placement and fill figures read unknown, never zero.
  • /api/holdings + payload.holdings_payload. Each row has cost, entry gate, attestation, the stop (only when one was recorded), the bracket (#641 judgement unchanged, _bracket_field now takes (has_bracket, initial_stop, mode)), placement, and expected/actual/slippage. There are no mark/value/unrealized/stop-distance columns.
  • holdingsView:
    • One table per rule from the report's own rules list.
    • The stop column is drawn only when stop_rows_present is non-empty.
    • Each tranche has a realized-legs disclosure.
    • Then Recent orders by keel: the compact /api/orders table, with no cancel column and no detail disclosure, keeping the scope and status tabs. If that secondary read fails, a sentence says so; it is never shown as an empty table.
  • Removed in lockstep:
    • routes /positions and /orders, and /api/positions;
    • read_positions, positions_payload, _position_row_payload, _cancel_payload, _CANCEL_STATES, and the cancel key on order rows;
    • positionsView, ordersView, cancelCell, openCancelHelp, orderDetail;
    • the .cancelhelp and .linklike CSS.
    • /positions and /orders joined RETIRED_CLIENT_ROUTES, so old links serve the shell and land on Pulse.
  • No close or cancel controls. A structural pin counts buttons and listeners (0) across the Holdings builders. classify_cancel and keel orders cancel stay in the CLI.
  • Operator text that pointed at "the console's Positions view" (keel positions close, keel doctor ledger drift) now points at Holdings.
  • The orders badge no longer says that every state change runs through the CLI. That stopped being true once PR1 added HALT; the badge now reads "places and cancels no orders".
  • Runbook console section updated. The README line is already accurate. docs.js TERMS is unaffected.

…truncated — full description in #959.

console: Rules view absorbs Insights and Research; charts and journal retired (#13 PR3) (#963)

Closes #953

PR3 of 4 in "Console — four engine views". Nav after this PR: Pulse, Setup, Holdings, Balances, Timeline, Rules, Venues, Plans (+ Docs).

What changed

  • /api/rules now carries records (track records + promotion-gate distances, from build_insights_report, the service keel insights summary calls) and closed_trade_count. records is a required keyword on rules_payload. It is not sortable: rules stays the one sortable collection, and ?sort=expectancy is a 400 (ruling 10).
  • Research: the four /api/research/* endpoints keep their readers, needs_database=False and empty sort surface. Only html_route moved to /rules (ruling 9).
  • Rules view, in page order: ledger, params, recordsSection (no sort pair; gate cards drawn when a gate is present), the DCA card (unchanged: it shows the CLI command and has no buttons), then the research record (trialsSection, matrixSection, gauntletSection, slippageSection). Every reading after the ledger is secondary. A read that fails comes through as null, and its section says so.
  • Removed together: /insights, /research (added to RETIRED_CLIENT_ROUTES, so they land on Pulse); /api/insights, /api/journal, read_insights, read_journal, _status_report, _journal_limit; the journal/equity constants; server._JOURNAL_LIMIT; journal.DEFAULT_NOTES_LIMIT (dead); insights_payload and the journal, curve, series and discretionary-journal builders; chart.js and its PRECACHE line; main.js's #602 zoom/pan/export block; the .chart* and data-point-index CSS. The services (build_equity_series/curve, build_journal_report, gather_journal) and the CLI are unchanged (ruling 11).
  • Docs: runbook view list and a new Rules. paragraph saying where each retired piece went (keel insights journal, keel journal list, Timeline rows). Stale comments that pointed at the retired views are reworded.

Rulings (build)

  • records is a required keyword, not None → count(0) as the plan said. A zero for a count nobody passed is the unknown-as-zero shape.
  • The CSV export link has no class. The plan renamed it to .linkaction. button.chartaction never matched the <a>, so it was unstyled before, and the rule went with the charts. Keeping the link as it was means it looks the same.
  • Gate-card ids include the card's position. The browser check found five turtle_breakout cards that all had the id h-gatedist-turtle_breakout, so opening one reopened all five on the next poll. The data side of this is filed as #962.
  • The research record now needs a deployment to be seen in the browser. The Rules primary is /api/rules, which needs a database. The research endpoints still answer without one, and the CLI still shows the record.

…truncated — full description in #963.

console: Audit & Setup view; Timeline, Setup, Venues, Balances and Plans retired -- four views (#13 PR4) (#966)

Closes #954

This is PR4 of 4 in "Console — four engine views". After it the nav is Pulse · Holdings · Rules · Audit & Setup · Docs.

What changed

  • Timeline (service)
    • Cycle rows now name their blocked count: cycle: 3 signal(s), 2 blocked, 1 entered, 0 exited, 0 error(s). A cycle object with no blocked says blocked unknown; it is never shown as 0.
    • gather_timeline accepts a composite kind keel, which means {system, attestation}. The report echoes keel back, and kinds_present lists it first whenever either member is present.
    • The CSV export resolves keel the same way, so the file holds what the screen shows.
    • The CLI is unaffected: no keel timeline command exists. The API default is still every kind.
  • Audit & Setup view (/audit)
    • auditView reads /api/setup first (the primary), then /api/timeline and /api/venues.
    • It places three exported section builders:
      • timelineSection: kind chips, CSV link, chain verdict and table. If there is no database it says so, and if the read failed it gives the error.
      • setupSection: the stage tabs and the page-level job panel. The CSRF token is still remembered from this reading.
      • readinessSection: the readiness table only.
    • main.js seeds paramsFor("timeline").kind = "keel" once, at boot.
    • The stopped view's "Set keel up on this machine" link lands here.
  • Retired routes
    • /setup, /timeline, /venues, /balances and /plans are added to RETIRED_CLIENT_ROUTES, so the server still serves the shell for them.
    • A new MOVED_ROUTES map in main.js sends /setup, /timeline and /venues to /audit. /balances and /plans land on Pulse.
  • Removed together
    • /api/balances and /api/plans, with their readers, payload builders and helpers.
    • keel/commands/plans.py, and keel/commands/balances.py (see rulings).
    • positions.gather_positions(with_readiness=...), whose only caller was gather_balances.
    • balancesView, venuesView, venueCard, plansView, claimList, citation.
    • The .claims CSS and the session term in docs.TERMS (it labelled venueCard's row).
    • The tests for all of the above.
  • Held items from PR1–PR3
    • gates.py no longer cites a "modal" that prints the phrase.
    • keel serve --help and the runbook now name the real POST surface and its gates. They used to say "GET and HEAD only".
    • The serve startup line no longer says "read-only view".
    • test_server.ROUTES now equals API_ROUTES plus the CSV export, so it cannot drift again. The sweep loops assert that every route answered.
  • Docs
    • The runbook console section has one paragraph per view, including where each retired view went.
    • architecture.md and go-live-runbook.md are updated.

…truncated — full description in #966.

console: HALT gets its own token; the release token is served nowhere (#968, #969) (#970)

Closes #968
Closes #969

What changes

#968 (security). Console PR1 served gates_token on /api/pulse for the HALT button. The resume routes check that same token, and their phrases are public, so a cookie-only loopback session could POST /api/gates/resume and release the kill switch.

  • security.halt_token: a third HMAC derivation of the session token, under b"keel/web/halt/v1". Only the halt route accepts it, in a header of its own, HALT_HEADER = "X-Keel-Halt".
  • /api/pulse serves data.halt_token. The old gate key is gone. No endpoint serves gates_token.
  • /api/gates/resume and /api/gates/resume-entries are unchanged. They still need a loopback peer and bind, gates_token in X-Keel-Gate, and the phrase.
  • The client holds haltToken in actions.js and sends it only in X-Keel-Halt. No client module names X-Keel-Gate.
  • Updated docstrings: security.py, server.do_POST / _gated_action, gates.py, web/__init__.py, api.read_pulse, payload.pulse_payload. Also updated: docs/operator-runbook.md, docs/remote-access.md and keel serve --help.

#969 (minor).

  • MOVED_ROUTES now covers positions/orders → holdings and insights/research → rules.
  • Gated actions answer through the new api.gate_document(key, done). The halt response's message is KILL_ENGAGED_LINE, and the response carries no changed claim.
  • Fixed stale text: the noscript paragraph, "one page of eleven", and the POLL_MS comment.

Rulings

  • Dedicated header X-Keel-Halt, not X-Keel-Gate. GATES_HEADER's own docstring says that tokens derived separately are not sent through one field. With two headers, each branch reads only its own header.
  • Halt response has no changed key. The gated operations don't report whether state moved, so "done" or "already done" would be a claim the server cannot back up.

Tests (written first, red before green)

  • (a) Read the token Pulse serves, halt, then POST each resume route with that token in either header. Both return 403, the kill switch stays engaged, and the control request with gates_token returns 200.
  • (b) Walk every API_ROUTES document and the CSV export, on a deployment and on an empty machine. No string, key or header carries gates_token.
  • (c) The halt succeeds with the halt token. It is refused (403, nothing written) with none, with the gates token in either header, with the CSRF token, or with the halt token in the gate header.
  • (d) runHalt posts { "<security.HALT_HEADER>": haltToken }. Exactly one module sends it and one module holds data.halt_token.
  • #969: the response carries the engine line. The moved-route tables are pinned in lockstep with RETIRED_CLIENT_ROUTES (moved names plus home-landing names cover the whole set).
  • Mutation check: 8/8 mutants killed. Each mutant was confirmed applied by hash and restored from a saved copy.

Gates

ruff check keel tests packages clean · ruff format --check . clean · bare mypy clean (507 files) · pytest 8243 passed, 3 skipped · node --check clean on every static/js/*.js and sw.js.

…truncated — full description in #970.

console: Holdings per rule row; DCA tranches read "no stop by design" (#960, #961) (#972)

Closes #960
Closes #961

What changes

#961: one bracket verdict, read by three surfaces.

  • status.bracket_verdict is now the only #641 judgement. These all read it:
    • keel status (_bracket_display);
    • the bracket cell on Pulse's roll-call and on Holdings (payload._bracket_field);
    • Pulse's missing-bracket count (pulse.build_pulse).
  • A non-paper tranche is NEUTRAL "no stop by design" when both of these hold:
    • its rule kind declares that it never brackets. This is Rule.places_bracket = False, a class attribute declared once, on Dca;
    • it recorded no real stop.
  • Every other non-paper tranche without a bracket stays WARN "NO bracket":
    • a DCA tranche that recorded a stop;
    • any non-DCA tranche, with or without a stop. The PAXG tranche (#811) is one of these;
    • a tranche whose kind keel does not know.
  • Pulse's "brackets missing" count no longer counts DCA tranches.
  • Paper is unchanged.
  • Zero is not a stop. Every attributed live DCA tranche records initial_stop = 0, which is the Dca.detect stop=0 sentinel. The live ledger's tranches 4–6 show this. A check of initial_stop is None alone would have left all of them warning.
    • status.recorded_stop treats a stop ≤ 0 as no stop.
    • The Holdings stop column reads the same helper, so a "$0.00" stop no longer sits next to "no stop by design".

#960: Holdings draws one table per rule row.

  • HoldingsReport.groups replaces rules. It is keyed on positions.rule_id, and each group's heading is kind · product · rule <id> (for example dca · BTC-USD · rule 6).
  • Unattributed tranches come last, as one table per kind (dca · unattributed, turtle_breakout · unattributed).
  • Group keys are built only from an integer or an ordinal (rule-6, unattributed-1). That makes the h-hold-<key> element ids unique and free of whitespace, which settles the earlier held H8.
  • Each row carries group. The client filters on row.group === group.key and prints the service's heading.

Rulings

  1. The heading uses the rule id, not cadence or amount.
    • Why: kind plus product does not tell same-product DCA rules apart (the paper ledger has rules 19 and 20, both BTC). Params can be edited and can coincide. The id is unique and is the handle keel rules takes.
    • Cost if wrong: the heading carries no budget or cadence; those are on Rules and Pulse.
  2. Unattributed tranches are split by kind.
    • Why: one "unattributed" table would put pre-v21 DCA legs and the PAXG turtle tranche together with nothing on screen to show their kind. Grouping by name used to show it.
    • Cost if wrong: up to one extra table per kind.
  3. A stop ≤ 0 counts as no stop, both for the verdict and for the Holdings stop column.
    • Why: it is the DCA sentinel, and a stop at zero protects nothing on a long.
    • Cost if wrong: a tranche whose recorded stop is really zero would read as having none. No real rule sizes against a zero stop.
  4. places_bracket is a new class attribute; accumulates is not reused.
    • Why: the sleeve-sell kinds also accumulate, and they open no tranche at all.
    • Cost if wrong: a future stop-less entry kind has to declare it. test_dca_is_the_one_kind_that_never_brackets forces that to be a deliberate change.
  5. Paper's bracket text is unchanged, including "paper stop 0" on paper DCA legs. The brief said paper behaviour stays as it is.

…truncated — full description in #972.

insights: one track record per rule; empty side reads unknown (#962, #965) (#975)

Closes #962
Closes #965

Rule track records on the Rules view, in keel insights summary and in its --json are now one record per rule. When one side of a sample is empty, its average now reads unknown instead of $0.00.

#962: one record per rule

Can trades be attributed to a rule? Yes.

  • orders.rule_id is set on every paper entry by a real paper cycle, and each exit copies its entry's id.
  • I checked the real paper databases read-only: every paper order in ~/keel/keel.db (dca and turtle_breakout) and in keel-paperhourly.db has a rule_id.

What changed:

  • paper.track_records_by_rule(repo, kind) splits a kind's paper journal by orders.rule_id. The splits do not overlap, and between them they hold every trade of the kind (pinned). track_record(repo, kind) stays as the pool for the whole kind, because the journal's R match only knows the kind.
  • Each record and its gate are built from that rule's own trades. RuleTrackRecord gains rule_id and product_id.
  • A rule outside the scope keeps its trades. For example, a disabled rule's trades used to be pooled into the paper rule of the same kind. They no longer are.
  • Trades journalled with no rule id (hand-built signals, or older fills) get one record per kind in scope, placed after the rules. That record has status="unattributed", no id, no product and no gate. They are not dropped, and they are not handed to a rule that happens to share the kind.
  • Rules view. The records table has new id and product columns, and is still unsorted. Each gate card summary reads kind · product · rule N · verdict, and the card's id is h-gatedist-<rule id>. The position-based id that PR3 added is gone.
  • CLI. Each record line reads [turtle_breakout] rule=10 product=BTC-USD status=…. The unattributed record prints rule=n/a product=n/a and a one-line note.

#965: an empty side is unknown, not $0.00

  • Report. RuleTrackRecord.avg_win and avg_loss are None when that side has no trade. This is decided by counting the side's trades, not by reading the zero.
  • Rules view. The payload's money(None) renders as "—" with state unknown.
  • CLI. The CLI prints n/a.
  • Gate verdicts are unchanged. BacktestResult, stats.py and promotion.py are untouched: pool_stats multiplies the zero by a zero count, and _realized_rr reads avg_loss_r. A test pins that each gate's (passing, blocking_reasons) equals check_floors on the same per-rule sample, and that the sample still holds 0 for the empty side.

…truncated — full description in #975.

Halt and keel serve hardening: drain every POST, HALT on a failed Pulse, serve keeps --external-host (#955, #958, #971) (#977)

Closes #955
Closes #958
Closes #971
Closes #976

Hardens the halt and keel serve. There are three server-side fixes, plus a client view for a Pulse report that fails.

#955: every POST ends at a request boundary

  • do_POST now runs its routing in _post() and calls _drain_request_body() in a finally. The halt answers without reading a body, so before this a body sent anyway stayed on a kept-alive socket and the next request was parsed as {}GET … (501).
  • Ruling: drain, not close. The drain is bounded the same way as before: an oversized or unreadable Content-Length closes the connection instead of reading. Why drain: the console's next read rides the same connection, and the test the issue asks for (a GET on the same socket answers 200) requires it. Cost if wrong: a client that declares a body and never sends it stalls only its own connection.
  • Ruling: the drain runs AFTER the answer. The halt has already engaged and been reported by then, so a slow body never delays the stop (gates.py: "stopping never slower than starting"). Cost if wrong: none for well-formed clients.
  • _drain_request_body now sets body_consumed, so the closing drain never reads twice after _refuse already drained.
  • Tests: one keep-alive socket. A halt POST with {}, a 2-byte body, non-JSON, or nested JSON each answers 200 with the switch engaged, and then a GET on the same socket answers 200. An oversized body still halts. Together these pin server-side that the halt reads and validates no body: the #970 review's mutant (halt calls _read_json_object and answers 400) is now killed.

#958: HALT survives a Pulse that cannot be built

  • When the read fails, /api/pulse's error envelope carries halt: {halt_token, resume_command}. The token is security.halt_token, never the gates token. The kill switch is read on its own path (api.pulse_halt → pulse.halt_resume_command), not through gather_pulse. Every other failure, and every pre-admission refusal, carries halt: null, so the key set stays constant.
  • Ruling: a kill switch that cannot be read shows the button, not the HALTED line. Halting a halted engine is idempotent and harmless, but a HALTED line over a running engine would hide the only stop. Cost if wrong: an operator may press HALT on an engine that is already halted.
  • Client: readingFrom forwards halt off a failure document. In paint, a Pulse failure that carries halt goes to rememberHaltToken (the one holder) and then to the new pulseFailedView, which is the same haltControl followed by the error. There is no extra confirm and no phrase.
  • Tests: with gather_pulse forced to raise, the response carries the halt token, the resume command follows the switch, and the served token halts (200). An unreadable switch shows the button. With every route's read made to raise, only Pulse carries halt. A refused Pulse carries halt: null. Structural client pins cover: Reading literals pair halt with attestations, only the failure arm reads document_.halt, the order of the paint branches, and that pulseFailedView is haltControl({ halt: reading.halt }) plus the error, with no report section.

…truncated — full description in #977.

Test log isolation, stale references, DCA stop distance, manifest name refusal (#964 #967 #973 #974) (#978)

Closes #964
Closes #967
Closes #973
Closes #974

Summary

#964: the test suite no longer leaves logs/keel.log in the checkout

  • Cause: configure_logging opens logging.file (default: the relative path logs/keel.log) against the cwd. Many CLI tests load a config that has no logging: section, so this was not one bad test. A bisect plugin found tests/commands/test_dca_cli.py as the first writer.
  • Fix: a new autouse fixture, tests/conftest.py::_run_in_tmp_path, runs every test from its own tmp_path and sets KEEL_HOME to that same folder.
  • Guard: another autouse fixture, _the_checkouts_logs_are_untouched, records the files under <repo>/logs and <invocation cwd>/logs (size and mtime) before each test. If either changed afterwards, it fails that test by name.
  • The timeline-export tests now set their own absolute logging.file. A probe that put an empty file at that path made 3 tests fail, so the pinned path is the one the export actually reads. The composite-kind test now asserts exact kinds instead of "may or may not appear".
  • Fallout from changing the cwd, now made explicit:
    • The simulate and telemetry tests pass --config <repo>/config.yaml.
    • test_research_record finds its module through __file__.
    • The alpaca .env test unsets KEEL_HOME.

#973: positions reads the stop through status.recorded_stop

  • stop_distance and stop_distance_pct are now None for a DCA tranche that stores initial_stop "0" (or below). PositionRow.initial_stop keeps the stored value, because Holdings' paper reading (shown_stop) names that 0.
  • Corrected the docstrings in positions.py and doctor.py.

#974: scripts/rule_manifest.py apply refuses the params keel rules add refuses

  • The params checks in add_rule_row moved into a new function, rules.validated_row_params. It covers unknown kind, type mismatches, unknown kwargs, the rule's own validation, non-finite values and dropped params, which includes name.
  • apply runs it on every manifest entry. A refused entry prints REFUSED: kind:product: <the same words add_rule_row prints> and is not written; the other entries still apply, and the exit code is 1.
  • The committed deploy/live-rules.json still applies cleanly.

#967: stale references corrected, each pinned by a test that reads the code

  • _common.record_cash_posture_refutation named keel balances and keel brokers list. It now names the real callers that pass no repo; a test resolves every keel ... it cites through click.
  • server._REFRESH_SEC is deleted. Its two citations (events.py, api.py) now point at main.js's POLL_MS. Tests check that every private constant in keel/web/*.py is read somewhere in its module, and that any `server._CONST` cited anywhere in keel/ exists.
  • capabilities.render_lines no longer mentions a browser view. A test requires its docstring to name every module that imports it.
  • gates.py: "the other seven" is now "eight", and the count is derived from CAPABILITIES. "LAST of four checks" is now "seven". That count is taken from the AST of do_POST and _gated_action (their top-level if not guards, plus the phrase), and each guard function must be named in the docstring.

…truncated — full description in #978.

tests: read the write guards from _post (main red after #977 + #978) (#984)

main is red after #977 and #978 merged together: tests/web/test_web_prose_stays_true.py::test_the_phrase_check_names_its_place_among_every_check_before_it fails with '_gated_action' was never reached.

Why: #978's test walks do_POST's refusing guards up to the _gated_action dispatch. #977 (#955) turned do_POST into a wrapper that runs _post and then drains the body in a finally, so the guards and the dispatch now live in _post.

Fix: the test reads _post. The stated count is still right: four guards in _post, plus two in _gated_action, plus the phrase, makes seven. So gates.py's "seven checks" sentences stay as they are.

Verified

  • Mutation check: removing the Sec-Fetch-Site guard from _post makes the test fail.
  • Full suite: 8405 passed, 3 skipped.
  • ruff, ruff format and mypy are clean.

insights+promotion: an empty sample side reads as empty, not as Infinity or zero (#979, #980) (#986)

Closes #979
Closes #980

What changes

#979: a ratio with an empty side reads the same on the page and at the terminal.
RuleTrackRecord gains two properties, realized_rr_reason and profit_factor_reason. Each one says which side of the sample is empty, or None when the figure is a real measurement. The side is decided from avg_win/avg_loss being None, which _side_average sets by counting trades, never from a zero. Both surfaces render the reason through one function, insights.undefined_reading, so they cannot drift apart:

sample profit factor (CLI = page) realized rr (CLI = page) page state
all wins n/a (no losses yet) (was Infinity / —) n/a (no losses yet) (was — on the page) neutral
all scratches (0/0) n/a (no wins or losses yet) (was 0.00) n/a (no wins or losses yet) unknown
all losses 0.00, a real measurement, unchanged n/a (no wins yet) (was — on the page) unknown
n = 0 n/a / —, unchanged n/a (no trades yet) / —, unchanged unknown

The gate card's realized_rr uses the record's reason, because the gate is judged on that same sample. Engine values (BacktestResult.profit_factor stays Infinity/0), --json output and gate verdicts are unchanged.

#980: check_floors / _pooled_floors reasons stop printing a fake zero.

  • n = 0: win_rate unknown: no closed trades, so min_win_rate 0.55 is not met (was win_rate 0.0 < …). The no-R refusal now says no R: no closed trades, … (was "none of the 0 closed trades carries an initial risk").
  • No winning trade in the R sample: rr_r unknown: no winning trade among the 3 trades with R, so min_rr 1.5 is not met (in R) (was rr_r 0 < min_rr 1.5). This is decided by counting n_wins_r, and only when the ratio really is that 0.
  • The same wording applies on the pooled path, with the pooled prefix.
  • An all-losses win_rate 0.0 over a traded sample is a measurement and keeps its number.
  • A verdict-equality pin (test_rewording_the_empty_sample_reasons_changes_no_verdict_and_no_reason_count) freezes passing and the reason count for every reworded shape. It was written and green on the old code before any wording changed.

Rulings

  • The CLI wording is n/a (<reason>) on both surfaces, not ∞. It matches the CLI's existing rr=n/a (no losses yet), and the page cell then equals the terminal token exactly.
  • On the page, "no losses yet" is neutral and "no wins yet" / "no wins or losses yet" are unknown. A ratio with no losses is unbounded in the trader's favour, and the R gate treats it as clearing, so it is known. The other two have no average win to measure with, which is #965's ruling.
  • The page field's value is "" for an undefined ratio. Putting Infinity on the wire would mean the serialiser inventing a figure.
  • The pooled path was reworded too. It copies check_floors' axes, so leaving it alone would keep the fake zero on the pooled gate.

…truncated — full description in #986.

keel serve: socket timeout, GET drain, halt never creates a DB, halt outlives the remote lifetime; stale citations (#981 #982 #983) (#987)

Closes #981
Closes #982
Closes #983

#981 — keel serve hygiene

  • Socket timeout. REQUEST_TIMEOUT_SECONDS = 60 is set on the bound handler by build_server, so StreamRequestHandler.setup applies it to every accepted socket. A refused POST that declares Content-Length: 100 and sends nothing now gets its refusal and is closed; an idle kept-alive connection is dropped instead of parking a thread in readline.
  • The halt is not slowed. The timeout is per socket operation. The halt reads no body and answers before do_POST's drain, so only the drain that comes after the answer waits on a body that never arrives. A test pins this under a 0.5 s timeout.
  • GET bodies are drained. A KeelHandler.handle_one_request override drains a GET's or HEAD's body at the request boundary once the response is out, unless the connection is already closing. This is #955's shape on the read path. do_GET is deliberately left byte-for-byte as on main, so CodeQL's existing self.path dataflow alerts are not re-attributed to this PR.
  • The timeout bounds silence, not holding. A client that trickles a byte inside every window is not cut off (slowloris). That is accepted: threads are unpooled, so an attacker can open more connections anyway.
  • The halt never creates a database. api.open_repo_to_write opens through the new db.connect_existing (mode=rw URI, otherwise the same as connect), which refuses a missing file. A halt that can't be recorded now returns a stated 500 ("The halt could not be recorded … Run keel kill") instead of a reset connection.

#982 — stale citations

engine._assemble_cts_context, backtest._closed_trade (x2) and simulate._slippage_for_products are corrected. A new test resolves every backticked engine. / backtest. / simulate. citation in keel/ against an allowlist of modules. A citation is excused only when that exact string is a constant in the cited module's source (its event kinds, engine.no_signal / engine.setup_detected). A guard test checks that each allowlisted stem names exactly one file in keel/.

#983 — HALT outlives the remote session lifetime (owner decision, option 2)

_post passes outlives_the_lifetime=self._is_a_tokened_halt(path) to _admitted. The flag is true only for /api/gates/halt carrying this session's halt token. The halt still needs the session cookie, and _gated_action still checks the token. If an expired session sends the halt without the token, it still gets "Session expired". Releases, setup writes and every read keep the lifetime. These docstrings are updated: security.REMOTE_SESSION_MAX_AGE_SECONDS, session_expired_at, _admitted, do_POST, _gated_action, gates.HALT, gates.run_gated_action.

The "Session expired" refusal now also says "To stop trading now, run keel kill at the terminal." The exemption serves a console still showing a Pulse painted before expiry, or any client that holds the token. The client half is filed as #985. The console's refusedView draws no HALT, so the next Pulse repaint after expiry still hides the button that the server would now accept.

…truncated — full description in #987.

chore(deps-dev): update uv-build requirement from <0.13.0,>=0.10.4 to >=0.10.4,<0.14.0 in /packages/keel-broker-kraken in the python-dependencies group (#990)

Updates the requirements on uv-build to permit the latest version.
Updates uv-build to 0.13.0

Release notes

Sourced from uv-build's releases.

0.13.0

Release Notes

Released on 2026-10-09.

uv 0.13.0 makes Python 3.15 the default stable Python version. We've also included several breaking changes to improve correctness, performance, and compatibility, described below.

We expect most users to be able to upgrade without making changes.

While not a breaking change, this release also updates the format of many of uv's cache entries to improve performance. uv may download or rebuild dependencies after upgrading, because some cached entries from earlier versions cannot be reused. Multiple versions of uv can still safely share the same cache directory.

There are no breaking changes to the configuration of the uv build backend. If your [build-system] table includes an upper bound on uv_build, update it to allow uv_build 0.13, e.g., uv_build>=0.13.0,<0.14.

Breaking changes

  • Use Python 3.15 as the default stable version

    The default stable Python version has changed from 3.14 to 3.15. This affects Python downloads when no version is requested or pinned, e.g., when running uv python install.

    uv continues to use compatible Python installations that are already present. For example, uv venv can still use an installed Python 3.14. If no suitable interpreter is installed and automatic downloads are enabled, commands such as uv venv and uvx python can now download Python 3.15.

    You can opt out of this behavior by requesting Python 3.14 explicitly, e.g., uv venv --python 3.14. For projects, use uv python pin 3.14 to record the version in .python-version.

  • Honor --require-hashes in included constraints files (#22275)

    Previously, uv ignored --require-hashes in constraints files included with -c from a requirements file. Now, uv honors the directive and requires hashes for all requirements in the installation. Installs that previously succeeded can now fail if a requirement is missing a hash.

    You cannot opt out while the directive is present. Add the missing hashes to your requirements, or remove the --require-hashes directive from the included constraints file if hash checking is not intended.

  • Prefer native Python on Windows ARM64 (#22100)

    Previously, ARM64 builds of uv preferred emulated x86_64 Python installations because native wheel support was limited. Now, uv prefers native ARM64 (aarch64) interpreters across Python versions.

    …truncated — full description in #990.

    chore(deps-dev): update uv-build requirement from <0.13.0,>=0.10.4 to >=0.10.4,<0.14.0 in the python-dependencies group (#991)

    Updates the requirements on uv-build to permit the latest version.
    Updates uv-build to 0.13.0

    Release notes

    Sourced from uv-build's releases.

    0.13.0

    Release Notes

    Released on 2026-10-09.

    uv 0.13.0 makes Python 3.15 the default stable Python version. We've also included several breaking changes to improve correctness, performance, and compatibility, described below.

    We expect most users to be able to upgrade without making changes.

    While not a breaking change, this release also updates the format of many of uv's cache entries to improve performance. uv may download or rebuild dependencies after upgrading, because some cached entries from earlier versions cannot be reused. Multiple versions of uv can still safely share the same cache directory.

    There are no breaking changes to the configuration of the uv build backend. If your [build-system] table includes an upper bound on uv_build, update it to allow uv_build 0.13, e.g., uv_build>=0.13.0,<0.14.

    Breaking changes

    • Use Python 3.15 as the default stable version

      The default stable Python version has changed from 3.14 to 3.15. This affects Python downloads when no version is requested or pinned, e.g., when running uv python install.

      uv continues to use compatible Python installations that are already present. For example, uv venv can still use an installed Python 3.14. If no suitable interpreter is installed and automatic downloads are enabled, commands such as uv venv and uvx python can now download Python 3.15.

      You can opt out of this behavior by requesting Python 3.14 explicitly, e.g., uv venv --python 3.14. For projects, use uv python pin 3.14 to record the version in .python-version.

    • Honor --require-hashes in included constraints files (#22275)

      Previously, uv ignored --require-hashes in constraints files included with -c from a requirements file. Now, uv honors the directive and requires hashes for all requirements in the installation. Installs that previously succeeded can now fail if a requirement is missing a hash.

      You cannot opt out while the directive is present. Add the missing hashes to your requirements, or remove the --require-hashes directive from the included constraints file if hash checking is not intended.

    • Prefer native Python on Windows ARM64 (#22100)

      Previously, ARM64 builds of uv preferred emulated x86_64 Python installations because native wheel support was limited. Now, uv prefers native ARM64 (aarch64) interpreters across Python versions.

      …truncated — full description in #991.

      chore(deps-dev): update uv-build requirement from <0.13.0,>=0.10.4 to >=0.10.4,<0.14.0 in /packages/keel-core in the python-dependencies group (#992)

      Updates the requirements on uv-build to permit the latest version.
      Updates uv-build to 0.13.0

      Release notes

      Sourced from uv-build's releases.

      0.13.0

      Release Notes

      Released on 2026-10-09.

      uv 0.13.0 makes Python 3.15 the default stable Python version. We've also included several breaking changes to improve correctness, performance, and compatibility, described below.

      We expect most users to be able to upgrade without making changes.

      While not a breaking change, this release also updates the format of many of uv's cache entries to improve performance. uv may download or rebuild dependencies after upgrading, because some cached entries from earlier versions cannot be reused. Multiple versions of uv can still safely share the same cache directory.

      There are no breaking changes to the configuration of the uv build backend. If your [build-system] table includes an upper bound on uv_build, update it to allow uv_build 0.13, e.g., uv_build>=0.13.0,<0.14.

      Breaking changes

      • Use Python 3.15 as the default stable version

        The default stable Python version has changed from 3.14 to 3.15. This affects Python downloads when no version is requested or pinned, e.g., when running uv python install.

        uv continues to use compatible Python installations that are already present. For example, uv venv can still use an installed Python 3.14. If no suitable interpreter is installed and automatic downloads are enabled, commands such as uv venv and uvx python can now download Python 3.15.

        You can opt out of this behavior by requesting Python 3.14 explicitly, e.g., uv venv --python 3.14. For projects, use uv python pin 3.14 to record the version in .python-version.

      • Honor --require-hashes in included constraints files (#22275)

        Previously, uv ignored --require-hashes in constraints files included with -c from a requirements file. Now, uv honors the directive and requires hashes for all requirements in the installation. Installs that previously succeeded can now fail if a requirement is missing a hash.

        You cannot opt out while the directive is present. Add the missing hashes to your requirements, or remove the --require-hashes directive from the included constraints file if hash checking is not intended.

      • Prefer native Python on Windows ARM64 (#22100)

        Previously, ARM64 builds of uv preferred emulated x86_64 Python installations because native wheel support was limited. Now, uv prefers native ARM64 (aarch64) interpreters across Python versions.

        …truncated — full description in #992.

        chore(deps-dev): update uv-build requirement from <0.13.0,>=0.10.4 to >=0.10.4,<0.14.0 in /packages/keel-broker-api in the python-dependencies group (#993)

        Updates the requirements on uv-build to permit the latest version.
        Updates uv-build to 0.13.0

        Release notes

        Sourced from uv-build's releases.

        0.13.0

        Release Notes

        Released on 2026-10-09.

        uv 0.13.0 makes Python 3.15 the default stable Python version. We've also included several breaking changes to improve correctness, performance, and compatibility, described below.

        We expect most users to be able to upgrade without making changes.

        While not a breaking change, this release also updates the format of many of uv's cache entries to improve performance. uv may download or rebuild dependencies after upgrading, because some cached entries from earlier versions cannot be reused. Multiple versions of uv can still safely share the same cache directory.

        There are no breaking changes to the configuration of the uv build backend. If your [build-system] table includes an upper bound on uv_build, update it to allow uv_build 0.13, e.g., uv_build>=0.13.0,<0.14.

        Breaking changes

        • Use Python 3.15 as the default stable version

          The default stable Python version has changed from 3.14 to 3.15. This affects Python downloads when no version is requested or pinned, e.g., when running uv python install.

          uv continues to use compatible Python installations that are already present. For example, uv venv can still use an installed Python 3.14. If no suitable interpreter is installed and automatic downloads are enabled, commands such as uv venv and uvx python can now download Python 3.15.

          You can opt out of this behavior by requesting Python 3.14 explicitly, e.g., uv venv --python 3.14. For projects, use uv python pin 3.14 to record the version in .python-version.

        • Honor --require-hashes in included constraints files (#22275)

          Previously, uv ignored --require-hashes in constraints files included with -c from a requirements file. Now, uv honors the directive and requires hashes for all requirements in the installation. Installs that previously succeeded can now fail if a requirement is missing a hash.

          You cannot opt out while the directive is present. Add the missing hashes to your requirements, or remove the --require-hashes directive from the included constraints file if hash checking is not intended.

        • Prefer native Python on Windows ARM64 (#22100)

          Previously, ARM64 builds of uv preferred emulated x86_64 Python installations because native wheel support was limited. Now, uv prefers native ARM64 (aarch64) interpreters across Python versions.

          …truncated — full description in #993.

          chore(deps-dev): update uv-build requirement from <0.13.0,>=0.10.4 to >=0.10.4,<0.14.0 in /packages/keel-broker-alpaca in the python-dependencies group (#994)

          Updates the requirements on uv-build to permit the latest version.
          Updates uv-build to 0.13.0

          Release notes

          Sourced from uv-build's releases.

          0.13.0

          Release Notes

          Released on 2026-10-09.

          uv 0.13.0 makes Python 3.15 the default stable Python version. We've also included several breaking changes to improve correctness, performance, and compatibility, described below.

          We expect most users to be able to upgrade without making changes.

          While not a breaking change, this release also updates the format of many of uv's cache entries to improve performance. uv may download or rebuild dependencies after upgrading, because some cached entries from earlier versions cannot be reused. Multiple versions of uv can still safely share the same cache directory.

          There are no breaking changes to the configuration of the uv build backend. If your [build-system] table includes an upper bound on uv_build, update it to allow uv_build 0.13, e.g., uv_build>=0.13.0,<0.14.

          Breaking changes

          • Use Python 3.15 as the default stable version

            The default stable Python version has changed from 3.14 to 3.15. This affects Python downloads when no version is requested or pinned, e.g., when running uv python install.

            uv continues to use compatible Python installations that are already present. For example, uv venv can still use an installed Python 3.14. If no suitable interpreter is installed and automatic downloads are enabled, commands such as uv venv and uvx python can now download Python 3.15.

            You can opt out of this behavior by requesting Python 3.14 explicitly, e.g., uv venv --python 3.14. For projects, use uv python pin 3.14 to record the version in .python-version.

          • Honor --require-hashes in included constraints files (#22275)

            Previously, uv ignored --require-hashes in constraints files included with -c from a requirements file. Now, uv honors the directive and requires hashes for all requirements in the installation. Installs that previously succeeded can now fail if a requirement is missing a hash.

            You cannot opt out while the directive is present. Add the missing hashes to your requirements, or remove the --require-hashes directive from the included constraints file if hash checking is not intended.

          • Prefer native Python on Windows ARM64 (#22100)

            Previously, ARM64 builds of uv preferred emulated x86_64 Python installations because native wheel support was limited. Now, uv prefers native ARM64 (aarch64) interpreters across Python versions.

            …truncated — full description in #994.

            chore(deps-dev): update uv-build requirement from <0.13.0,>=0.10.4 to >=0.10.4,<0.14.0 in /packages/keel-broker-robinhood in the python-dependencies group (#995)

            Updates the requirements on uv-build to permit the latest version.
            Updates uv-build to 0.13.0

            Release notes

            Sourced from uv-build's releases.

            0.13.0

            Release Notes

            Released on 2026-10-09.

            uv 0.13.0 makes Python 3.15 the default stable Python version. We've also included several breaking changes to improve correctness, performance, and compatibility, described below.

            We expect most users to be able to upgrade without making changes.

            While not a breaking change, this release also updates the format of many of uv's cache entries to improve performance. uv may download or rebuild dependencies after upgrading, because some cached entries from earlier versions cannot be reused. Multiple versions of uv can still safely share the same cache directory.

            There are no breaking changes to the configuration of the uv build backend. If your [build-system] table includes an upper bound on uv_build, update it to allow uv_build 0.13, e.g., uv_build>=0.13.0,<0.14.

            Breaking changes

            • Use Python 3.15 as the default stable version

              The default stable Python version has changed from 3.14 to 3.15. This affects Python downloads when no version is requested or pinned, e.g., when running uv python install.

              uv continues to use compatible Python installations that are already present. For example, uv venv can still use an installed Python 3.14. If no suitable interpreter is installed and automatic downloads are enabled, commands such as uv venv and uvx python can now download Python 3.15.

              You can opt out of this behavior by requesting Python 3.14 explicitly, e.g., uv venv --python 3.14. For projects, use uv python pin 3.14 to record the version in .python-version.

            • Honor --require-hashes in included constraints files (#22275)

              Previously, uv ignored --require-hashes in constraints files included with -c from a requirements file. Now, uv honors the directive and requires hashes for all requirements in the installation. Installs that previously succeeded can now fail if a requirement is missing a hash.

              You cannot opt out while the directive is present. Add the missing hashes to your requirements, or remove the --require-hashes directive from the included constraints file if hash checking is not intended.

            • Prefer native Python on Windows ARM64 (#22100)

              Previously, ARM64 builds of uv preferred emulated x86_64 Python installations because native wheel support was limited. Now, uv prefers native ARM64 (aarch64) interpreters across Python versions.

              …truncated — full description in #995.

              chore(deps-dev): update uv-build requirement from <0.13.0,>=0.10.4 to >=0.10.4,<0.14.0 in /packages/keel-broker-coinbase in the python-dependencies group (#996)

              Updates the requirements on uv-build to permit the latest version.
              Updates uv-build to 0.13.0

              Release notes

              Sourced from uv-build's releases.

              0.13.0

              Release Notes

              Released on 2026-10-09.

              uv 0.13.0 makes Python 3.15 the default stable Python version. We've also included several breaking changes to improve correctness, performance, and compatibility, described below.

              We expect most users to be able to upgrade without making changes.

              While not a breaking change, this release also updates the format of many of uv's cache entries to improve performance. uv may download or rebuild dependencies after upgrading, because some cached entries from earlier versions cannot be reused. Multiple versions of uv can still safely share the same cache directory.

              There are no breaking changes to the configuration of the uv build backend. If your [build-system] table includes an upper bound on uv_build, update it to allow uv_build 0.13, e.g., uv_build>=0.13.0,<0.14.

              Breaking changes

              • Use Python 3.15 as the default stable version

                The default stable Python version has changed from 3.14 to 3.15. This affects Python downloads when no version is requested or pinned, e.g., when running uv python install.

                uv continues to use compatible Python installations that are already present. For example, uv venv can still use an installed Python 3.14. If no suitable interpreter is installed and automatic downloads are enabled, commands such as uv venv and uvx python can now download Python 3.15.

                You can opt out of this behavior by requesting Python 3.14 explicitly, e.g., uv venv --python 3.14. For projects, use uv python pin 3.14 to record the version in .python-version.

              • Honor --require-hashes in included constraints files (#22275)

                Previously, uv ignored --require-hashes in constraints files included with -c from a requirements file. Now, uv honors the directive and requires hashes for all requirements in the installation. Installs that previously succeeded can now fail if a requirement is missing a hash.

                You cannot opt out while the directive is present. Add the missing hashes to your requirements, or remove the --require-hashes directive from the included constraints file if hash checking is not intended.

              • Prefer native Python on Windows ARM64 (#22100)

                Previously, ARM64 builds of uv preferred emulated x86_64 Python installations because native wheel support was limited. Now, uv prefers native ARM64 (aarch64) interpreters across Python versions.

                …truncated — full description in #996.

                chore(deps-dev): update uv-build requirement from <0.13.0,>=0.10.4 to >=0.10.4,<0.14.0 in /packages/keel-broker-fake in the python-dependencies group (#997)

                Updates the requirements on uv-build to permit the latest version.
                Updates uv-build to 0.13.0

                Release notes

                Sourced from uv-build's releases.

                0.13.0

                Release Notes

                Released on 2026-10-09.

                uv 0.13.0 makes Python 3.15 the default stable Python version. We've also included several breaking changes to improve correctness, performance, and compatibility, described below.

                We expect most users to be able to upgrade without making changes.

                While not a breaking change, this release also updates the format of many of uv's cache entries to improve performance. uv may download or rebuild dependencies after upgrading, because some cached entries from earlier versions cannot be reused. Multiple versions of uv can still safely share the same cache directory.

                There are no breaking changes to the configuration of the uv build backend. If your [build-system] table includes an upper bound on uv_build, update it to allow uv_build 0.13, e.g., uv_build>=0.13.0,<0.14.

                Breaking changes

                • Use Python 3.15 as the default stable version

                  The default stable Python version has changed from 3.14 to 3.15. This affects Python downloads when no version is requested or pinned, e.g., when running uv python install.

                  uv continues to use compatible Python installations that are already present. For example, uv venv can still use an installed Python 3.14. If no suitable interpreter is installed and automatic downloads are enabled, commands such as uv venv and uvx python can now download Python 3.15.

                  You can opt out of this behavior by requesting Python 3.14 explicitly, e.g., uv venv --python 3.14. For projects, use uv python pin 3.14 to record the version in .python-version.

                • Honor --require-hashes in included constraints files (#22275)

                  Previously, uv ignored --require-hashes in constraints files included with -c from a requirements file. Now, uv honors the directive and requires hashes for all requirements in the installation. Installs that previously succeeded can now fail if a requirement is missing a hash.

                  You cannot opt out while the directive is present. Add the missing hashes to your requirements, or remove the --require-hashes directive from the included constraints file if hash checking is not intended.

                • Prefer native Python on Windows ARM64 (#22100)

                  Previously, ARM64 builds of uv preferred emulated x86_64 Python installations because native wheel support was limited. Now, uv prefers native ARM64 (aarch64) interpreters across Python versions.

                  …truncated — full description in #997.

                  rules backtest: word an empty-side profit factor as insights does (#988) (#998)

                  Closes #988

                  What

                  keel rules backtest used to print stats.profit_factor raw. That meant Infinity for an all-wins sample, and a 0 that looks measured for an all-scratch sample (0/0) or for a sample with no closed trade. Since #986, keel insights summary and the Rules page word these cases instead. The backtest line now uses the same words:

                  sample engine value (unchanged) line before line now
                  all wins (incl. a zero-pnl win) Infinity / 0 profit_factor=Infinity / 0 profit_factor=n/a (no losses yet)
                  all scratches 0 profit_factor=0 profit_factor=n/a (no wins or losses yet)
                  no closed trade 0 win_rate=0.00% ... profit_factor=0 expectancy_px=0 max_drawdown_px=0 win_rate=n/a ... profit_factor=n/a expectancy_px=n/a max_drawdown_px=n/a (bare n/a for every figure, as render_summary prints at n=0)
                  all losses 0 profit_factor=0 profit_factor=0 (a measurement, unchanged)
                  both sides ratio ratio ratio (unchanged)

                  How

                  • keel/commands/insights.py: _profit_factor_reason(n_trades, avg_win, avg_loss) is now the single rule. It finds the empty side and drops "no wins yet", because an all-losses 0 is measured. RuleTrackRecord.profit_factor_reason calls it, and the new profit_factor_reason_of(stats) applies it to a BacktestResult. Each side is decided by counting trades through _side_average, as build_rule_track_record does.
                  • keel/commands/rules.py: at n=0 every unmeasured figure prints bare n/a. Otherwise the line prints undefined_reading(reason) when there is a reason, and the engine's own value when there isn't.

                  This changes the display only. BacktestResult, the gates and the console's returned stats are unchanged, and tests pin that. rules backtest has no --json.

                  Tests (TDD)

                  All new tests are in tests/commands/test_rules_services.py. They drive the real run_rule_backtest and replace only the engine run:

                  • Parametrized profit-factor test: cases are all wins, zero-pnl win, all scratches, no trades, all losses and mixed. It asserts the exact parsed field and that the engine value is untouched.
                  • n=0 test: asserts every figure on the line.
                  • Parity test: for each empty-side shape, the rendered field equals undefined_reading(RuleTrackRecord.profit_factor_reason) for the same stats.

                  Six mutations were each killed by these tests: raw line, no n=0 guard, zero-average sides, no win_rate guard, no px guard, and no no-wins filter.

                  Follow-up filed: #1000. The keel simulate edge table's R profit factor still prints inf / 0.000; its docstring calls that deliberate, so it was left out of scope.

                  fix(web): refuse HTTP/0.9 before dispatch with a framed 505 (#989) (#999)

                  Closes #989

                  What was wrong

                  • GET /api/config (no version, HTTP/0.9) leaves self.headers a plain dict. _admitted refuses on Host, _refuse drains, and _declared_body_length raised AttributeError on .defects. The client got a reset with no answer, and a traceback went to stderr.
                  • Found while writing the test: the explicit form GET /api/config HTTP/0.9 does get its headers parsed. With a valid Host and session it was admitted and answered in 0.9 framing (body, no status line). With Connection: keep-alive the socket stayed open, so the unframed JSON sat in front of the next HTTP/1.1 200 on the same connection.

                  Fix

                  KeelHandler.parse_request runs the stdlib parse first. If request_version == "HTTP/0.9" (the stdlib's own test for "answer without a status line"), it refuses with a 505 that has a status line and closes the connection. That covers both forms, before any verb runs, so every request a verb sees now has a parsed header block.

                  • do_GET / do_POST are untouched, so the CodeQL alerts on do_GET are not re-attributed.
                  • The halt is unaffected. No browser sends 0.9, the console's fetch included. A versionless POST was already a stdlib 400.

                  Tests (tests/web/test_server.py)

                  • Raw socket, GET {path}\r\n for /api/config, /, /api/gates/halt: the reply is a 505 status line, then a close, and capfd stderr is empty.
                  • Explicit HTTP/0.9 with session + keep-alive + a pipelined 1.1 GET: exactly one status (505), no "data" served, then a close.
                  • HTTP/1.0 and HTTP/1.1 GETs are still answered 200 with the document.
                  • Every socket wait is bounded (5 s timeout). Servers are torn down by _serving.
                  • Mutation-checked: no refusal, no status line, refusing 1.0 too, dispatching after the refusal, and raising in the handler (which proves the stderr pin works) each fail the tests.

                  Full suite: 8440 passed, 3 skipped. ruff, format and mypy are clean.

                  Console: a reloaded console keeps its HALT after the remote lifetime (#985) (#1001)

                  Closes #985

                  What changes

                  Since #983 (PR #987) the server takes a halt carrying the session cookie and the halt token past the 12 h remote session lifetime on keel serve --external-host, but /api/pulse, the only endpoint that serves the token, keeps the lifetime. So a console reloaded after the lifetime drew no HALT button.

                  This follows the design the owner approved on the issue:

                  • Server. The expired-session refusal on GET /api/pulse carries #958's {halt_token, resume_command} (api.pulse_halt) when the request carries this session's cookie (KeelHandler._expired_pulse_halt, keyed on ApiRoute.halt_on_failure, so it applies to Pulse only). Without the cookie, or with a wrong one, the refusal carries halt: null. Nothing else changes: it is still a 403 "Session expired" with data: null. No release, setup write or other read is reopened, no other endpoint serves the halt token, and the gates token is served nowhere (#968). do_GET is untouched.
                  • Client. In paint's data: null branch, the HALT arm (pulseFailedView) now comes before the 403 arm. It branches on whether halt is present, never on the refusal's title.
                  • Prose. Corrected the docstrings in server.py, security.py, gates.py, api.py, payload.py, api.js, render.js, actions.js and main.js that said a reloaded console cannot get the token. The #634 guard pin in test_pwa.py now allows the } else prefix and nothing else.

                  Tests (TDD: written first, failed because halt was None)

                  • With the cookie, an expired Pulse carries the halt token and the resume command, for both a running and a halted switch. The served token halts. The body contains no gates token, CSRF token or session token.
                  • With no cookie or a wrong cookie: halt: null and no token in the body.
                  • Every other /api/* GET (13 routes plus the CSV export, with a count asserted): halt: null.
                  • A POST to /api/pulse after expiry: halt: null.
                  • The served token releases neither Tier 1 action in either header, and the kill switch stays engaged.
                  • A fresh remote Pulse is unchanged: 200 with data.halt_token, envelope halt: null.
                  • Client structure: in paint, the halt arm comes before the refused arm, which comes before the stopped view. Each arm appears exactly once, and main.js contains no "Session expired" literal.

                  Mutation checks (each on a saved copy, with a check that the source changed, then restored): dropping the cookie check, the method check, the route check, the halt= argument or refusal_document's pass-through, serving the gates token, a dead client arm, and a branch on the title. All of them were killed.

                  Verification

                  • Full pytest: 8442 passed, 3 skipped. mypy, ruff and ruff format are clean. node --check is clean on main.js, render.js, api.js and actions.js.
                  • Real Chromium (Playwright) against a temp deployment on port 8791 with an external host and a lifetime set to end 45 s after start:
                    • Before the lifetime ended, Pulse rendered as usual with one HALT button.
                    • After it ended, a reload served the shell from the service worker. The Pulse page showed the HALT button above the "Session expired" card, with no reconnect form.
                    • Clicking HALT and confirming showed the line "HALTED — resume with keel resume". kill_switch read true from the database (opened read-only).
                    • A request without the cookie got halt: null.

                  …truncated — full description in #1001.

                  sim report: word an empty-side R profit factor as insights does (#1000) (#1003)

                  Closes #1000

                  What changed

                  The keel sim report's edge table printed the engine's raw Profit factor (R): inf when the R sample had no losing trade, and 0.000 when it held only scratches or a zero-R win. Those cells now use the wording that insights summary, the Rules page and rules backtest already use (#986/#998):

                  R sample before after
                  wins only inf n/a (no losses yet)
                  scratches only 0.000 n/a (no wins or losses yet)
                  zero-R win, no loss 0.000 n/a (no losses yet)
                  losses only 0.000 0.000 (a measured value, unchanged)
                  no R at all n/a n/a (unchanged)
                  • insights.profit_factor_r_reason_of(stats) sits next to profit_factor_reason_of. It calls the same _profit_factor_reason on the R sample (n_trades - n_excluded_no_risk) and decides each side by counting n_wins_r / n_losses_r, as the issue asks. It never reads a zero.
                  • report._profit_factor_r_cell renders the cell through undefined_reading, so the wording has one source and is not copied.
                  • BacktestResult / profit_factor_r, the promotion gates and pool_stats are unchanged. The test checks that the engine value is still Infinity after rendering.

                  Ruling

                  Change the display rather than keep inf. The old _r_cell docstring called inf deliberate, so I checked each reason it could still hold:

                  • Machine-read output: nothing parses the edge-table Markdown. The only readers are tests and people, and the figures stay on BacktestResult.
                  • Column width or sorting: none applies to a Markdown table.
                  • Units: a profit factor is a ratio, so an empty side means the same thing in R as in money.

                  None of these holds, so the docstring is updated. inf remains only for the degenerate case where both sides have trades but the losing R sums to zero.

                  Tests

                  • test_edge_section_words_an_empty_side_profit_factor_as_insights_does: one test covering all seven rows above plus the pooled row. It also asserts the fixtures' raw engine values first, so no case passes vacuously.
                  • Mutation-checked 5 pins: reading a zero instead of counting (win side and loss side), printing the raw cell, dropping the no-R guard, and dropping the reason branch. Every mutant failed the suite, and each mutated source was confirmed to differ from the saved copy with cmp.
                  • Full pytest: 8459 passed, 3 skipped. mypy, ruff check and ruff format --check are clean.

                  docs: remote-access names the two stop exceptions to the session lifetime (#1002) (#1005)

                  Closes #1002

                  #1001 merged from eebc202, but the branch's round-3 docs fix (35bd873) was pushed after the merge and never reached main. This PR cherry-picks it unchanged.

                  docs/remote-access.md now names the two exceptions to the 12-hour remote session lifetime. Both are for stopping trading:

                  • #983: a halt that carries the session cookie and the halt token is still accepted after the 12 hours.
                  • #985: an expired Pulse read that carries this session's cookie returns HALT's inputs, so a reloaded console still shows HALT.

                  The page also states what stays closed: no release, no setup write, no other read, and the release token is served nowhere.

                  Docs only. The full suite passes on this head: 8458 passed, 3 skipped.

                  sim report: word an empty-side Avg win/loss (R) by counting the R sample (#1004) (#1007)

                  Closes #1004

                  What

                  The keel sim edge table's Avg win (R) / Avg loss (R) cells printed the engine's empty-side 0 as 0.000, which reads as a measured flat side. They now print:

                  R sample Avg win (R) Avg loss (R)
                  all wins measured n/a (no losses yet)
                  all losses n/a (no wins yet) measured
                  all scratches n/a (no wins yet) n/a (no losses yet)
                  a side whose trades average exactly 0 R 0.000 0.000
                  no R at all n/a n/a

                  How

                  • keel/commands/insights.py: new avg_r_reason_of(stats, outcome), beside profit_factor_r_reason_of. It decides the side by counting the R sample (n_wins_r / n_losses_r), never by reading the average, and reuses the existing NO_WINS_YET / NO_LOSSES_YET / NO_TRADES_YET constants. No new strings.
                  • keel/sim/report.py: new _avg_r_cell, built the same way as _profit_factor_r_cell: bare n/a with no R, then undefined_reading(reason), otherwise _r_cell(value). keel.commands.insights is still imported lazily, so the existing subprocess import-isolation test keeps passing.
                  • BacktestResult, stats._r_aggregates, promotion.pool_stats and the gates are unchanged. pool_stats still multiplies by the engine's 0.

                  Tests (TDD)

                  • test_edge_section_words_an_empty_side_average_r_by_counting_the_r_sample fails on main (('wins:A', '1.500', '0.000')) and passes here. It covers all-wins, all-scratch, all-losses, a hand-built zero-R win plus zero-R loss (to check that sides are counted, not read), both sides, no trades, and the pooled row.
                  • test_avg_r_reason_of_an_empty_r_sample_is_no_trades_yet_on_either_side covers the empty-sample and all-unrisked branch.
                  • Mutation-checked 7 mutants, each confirmed applied with cmp and each killed: reading the average instead of the count (both sides), a swapped count, dropping the empty-sample branch, dropping the no-R guard, reverting the loss cell, and an outcome-blind value.
                  • Full suite: 8461 passed, 3 skipped. ruff check, ruff format --check and bare mypy are clean.

                  Console: serve the static shell past the remote lifetime to its own session (#1006) (#1008)

                  Closes #1006

                  What changed

                  On keel serve --external-host, past the 12 h session lifetime (#648), a GET/HEAD of the client shell carrying this session's cookie is now served instead of refused, so a browser with no cached console loads the page and reaches the expired-Pulse HALT that #985 added.

                  • KeelHandler._is_a_sessioned_shell_read (new), consulted inside _admitted. do_GET is untouched (CodeQL alerts point at it).
                  • "Shell" = membership of the decoded path in staticfiles.shell_request_paths(STATIC_ROOT), a list built from the package side: each shipped file with a known Content-Type, /, and every CLIENT_ROUTES / RETIRED_CLIENT_ROUTES name. The request path never reaches a filesystem call (that keeps CodeQL from seeing a second path-injection flow), and non-canonical spellings (/js/../index.html, /pulse/) are not exempted, so they're refused as expired. Anything under /api/ (events included) and /switch/ is excluded by prefix first, so a file planted under the static root cannot reopen one.
                  • No change to /api/*: every read and write keeps the lifetime except #983 (tokened halt POST) and #985 (cookie-matched expired /api/pulse carries {halt_token, resume_command}). The gates token stays served nowhere (#968). The shell is the package's files byte for byte, with no template, so there's no data and no token in it.
                  • Docstrings (server, security.REMOTE_SESSION_MAX_AGE_SECONDS, gates.HALT, staticfiles.resolve_client_route), docs/remote-access.md (now "three exceptions, all for the stop"), and the prose test that used / to demonstrate the lifetime (now /api/config).

                  Ruling (the issue's held question)

                  Ruling: the shell is served past the lifetime to this session's cookie only, not to any browser. Why: before the lifetime a cookieless request is refused the shell (test_static_assets_are_behind_the_same_admission_as_every_other_page: "not exempted ... just because it holds no secrets today"). Serving it to anyone after expiry would make an expired server more open to strangers than a live one. A cookieless browser gains nothing anyway, because the Pulse refusal draws HALT only for the cookie. A first visit through the printed ?token= address gets the cookie from the exchange, which runs before admission, so it's covered. The issue's premise that "a loopback server already serves it freely" doesn't hold: loopback gates the shell on the cookie too. Cost if wrong: a cookieless browser past the lifetime keeps getting the plain-text "Session expired" (which names keel kill) instead of a page that couldn't halt either.

                  …truncated — full description in #1008.

                  Clear the open CodeQL alerts (#1, #2, #3, #5, #7, #8) (#1009)

                  Clears the six open CodeQL code-scanning alerts on main. No alert is dismissed here; the owner decides dismissals.

                  Per alert

                  Alert Rule Verdict Fix Test
                  #3, #7 py/path-injection, keel/web/staticfiles.py False positive (the resolve() + relative_to() containment was real; CodeQL does not model relative_to as a sanitizer) resolve_static_asset now answers by exact name from _servable_files(root), a listing of real files whose resolved location stays under the root. The request path is only compared with names the package produced, never joined or walked. At least as strict: also refuses non-canonical spellings (sub/../index.html, ./x, a//b, trailing slash) that no browser sends. shell_request_paths reuses the same listing Existing traversal / null-byte / double-encoding tests still green, plus new: symlink out of root, symlinked directory out of root, in-root symlink resolves, symlink loop, non-canonical spellings refused (red before the fix)
                  #5 py/path-injection, keel/web/server.py _serve_static read False positive, same flow Cleared by the change above: the Path it reads is the listing's own value. No code was moved out of do_GET/_serve_static Covered by the wire-level traversal tests in test_server.py
                  #2 py/http-response-splitting, keel/web/server.py False positive for splitting (http.server splits the request line on whitespace, and parsed.path is never percent-decoded, so CR/LF cannot reach the header). Real but minor open redirect: /\evil.example?token=<valid> redirected to /\evil.example, which browsers treat as //evil.example (needs the session token, so low impact) The token-exchange Location is staticfiles.shell_redirect_target: the member of shell_request_paths the decoded path names, else /. No request byte can reach the header test_the_token_exchange_redirects_only_to_a_path_the_console_answers (encoded CRLF, %5C and raw backslash, /api/pulse, unknown name all go to /; /rules and /status are kept), and test_a_raw_carriage_return_in_the_request_line_injects_no_header (real control bytes over a socket)
                  #1 py/clear-text-logging-sensitive-data, scripts/robinhood_order_probe.py Near-real: the refusal echoed whatever the operator typed after --private-key-var / --api-key-var, so a key pasted where its variable name belongs went to the terminal The refusal names the missing flag and repeats nothing the operator or the .env supplied test_a_missing_credential_is_named_by_its_flag_and_echoes_no_value (red before the fix)
                  #8 py/bad-tag-filter, tests/web/test_client_assets.py False positive in practice (the input was lowercased first), but the regex was a hand-written tokenizer test_the_shell_ships_no_inline_script_or_style collects tags with html.parser; same assertions (every script has a src; the only classic script is exactly {"src": "/js/theme.js"}; it comes before the first stylesheet link; no <style>/<base>) Mutation-checked: an uppercase inline <SCRIPT >, swapping script and stylesheet order, and an extra attribute on the theme script each make it fail

                  …truncated — full description in #1009.

                  Bump certifi to 2026.10.23 for PYSEC-2026-4320 (#1011) (#1012)

                  Closes #1011

                  Problem

                  The security workflow's dependencies job fails on main: uv.lock pins certifi 2026.6.17, which has advisory PYSEC-2026-4320 (fixed in 2026.10.19). pip-audit printed certifi 2026.6.17 PYSEC-2026-4320 2026.10.19 and exited 1.

                  Approach

                  uv lock --upgrade-package certifi moves only the lock, to certifi 2026.10.23 (>= the fixed 2026.10.19). No pyproject or source changes.

                  Considered and rejected

                  None.

                  Verification

                  Run on the final tree, in the worktree:

                  $ uv lock --upgrade-package certifi
                  Updated certifi v2026.6.17 -> v2026.10.23
                  (exit 0)
                  $ git diff --stat
                   uv.lock | 6 +++---   (certifi only)
                  $ uv export --frozen --all-extras --no-hashes --no-emit-package <the 8 keel-* packages>  (flags copied from security.yml) > requirements.lock.txt
                  (exit 0)
                  $ uvx pip-audit --requirement requirements.lock.txt
                  No known vulnerabilities found
                  (exit 0; shell pipe hid the code, the tool reports clean)
                  $ uv sync --all-extras --dev
                  (exit 0)
                  $ uv run pytest -q -x -p no:cacheprovider
                  8502 passed, 3 skipped in 352.25s (0:05:52)
                  (exit 0 implied by the all-pass summary; pipe hid the code)
                  

                  Where to look first

                  • uv.lock: the certifi block (version, sdist, wheel hashes) is the only change.

                  Commits

                  1. Bump certifi to 2026.10.19 for PYSEC-2026-4320 (#1011)

                  Rail 17: warn when the attestation expires before a DCA window, and retry a vetoed window (#1010) (#1013)

                  Closes #1010

                  Problem

                  Rail 17's withdrawal attestation lasts 7 days. Some live DCA rules buy every 14 days. On 2026-10-23 the attestation (made 2026-10-10 19:25Z) had been expired for six days, and rail 17 vetoed all seven DCA signals for the 2026-10-22 bar. Nothing warned beforehand. A vetoed window was also lost until the next cadence multiple, which for a 14-day rule means a month between buys. Finally, the veto text said "exits and DCA-exempt paths are unaffected", which reads as if DCA is exempt. It is not.

                  Approach

                  • D1, the warning. One pure helper, attestations.dca_windows_after_expiry, holds all the arithmetic: the due day is the cadence bar plus one day, and a rule is warned when the expiry comes before due + 1 day. A never-raising wrapper, live_dca_windows_after_expiry, applies it to the live DCA rules. It stays silent unless the attestation is fresh (OK or EXPIRING) and says withdrawals are enabled (R15). Three surfaces call the wrapper and never restate the arithmetic:
                    • keel withdrawals attest --enabled prints a WARNING: line per rule.
                    • keel status prints the same line under the rail-17 line. It does this in live modes only, and the line is also in --json.
                    • keel doctor always emits attest.dca_cadence, as OK or WARN and never FAIL (R11).
                  • D2, the retry. Dca.detect stays pure and reads an optional per-cycle DcaWindowLedger. Only live cycles set it (R10), from two new Repository reads.
                    • A window counts as satisfied when a filled or pending live BUY's bar day (created_at//86400 - 1) is at or after the window start (R4, R5).
                    • A retry fires only if the rule persisted a signal, while live, for an earlier bar of the same window (R6, R13 revised: promotion time floored to midnight UTC; no promoted_at means no retry).
                    • A window that ends unbought is reported on the next cadence day as context["previous_window_missed_ts"] plus a WARNING agent.dca_window_missed (R7). It is never back-filled.
                    • If building the ledger fails, the rule falls back to cadence-only and a WARNING agent.dca_window_ledger_failed is logged (R14).
                    • With no ledger (paper, sim, backtest), behaviour is unchanged.
                  • D3, the text. The rail-17 UNKNOWN veto now says "(exits are unaffected; DCA buys are NOT exempt)". The guards module docstring now reads "like rails 11/16/17 (11 and 16 exempt DCA; 17 does not)" (R16).
                  • WITHDRAWAL_ATTESTATION_TTL_SEC is unchanged. No config, plist or DB file is touched. There is no CHANGELOG entry (R12); docs/rails/rail-17-withdrawal-attestation.md gains a section on this instead.

                  Considered and rejected

                  • Lengthening the TTL. The issue rejects it.
                  • Counting a window as satisfied when created_at falls inside it. A live order is placed the day after its bar. With that rule, a buy for a window's last bar would land in the next window and suppress that window's buy, and a cadence-1 rule would never buy again. Bar-day attribution (R4) avoids both.
                  • Surfacing the warning through attestations.survey(), so the web banner shows it. That would change the notification alert-once windows and the web banners. survey() is unchanged (R9); the web console can pick this up in a follow-up.

                  Verification

                  Commands run on the final tree, c55a39d:

                  …truncated — full description in #1013.

                  Rail 17 reads Coinbase; skipped DCA buys retry until the next window and alert on macOS (#1010) (#1016)

                  Closes #1010.

                  Live DCA buys kept stopping without notice whenever the 7-day withdrawal attestation (rail 17) lapsed, and a vetoed DCA window was lost for good. Since August that cost every window of the five 14-day rules. This replaces the attestation with a check keel makes itself, retries skipped DCA buys, and tells the operator every time a live buy is skipped.

                  Changed

                  • Rail 17 reads Coinbase. On every live BUY, keel reads allow_withdrawals from GET /v2/accounts for the wallet the order spends (USD/USDC) and the coin being bought. Both allowed → pass. Either disallowed → veto naming the wallet. A coin with no wallet yet is decided on the quote wallet. Venue unreadable, quote wallet missing, suspension store unreadable, or an adapter without the read → veto (fails closed). SELLs and paper are unaffected. The existing key is enough (can_transfer: false). The flag has only ever been observed true; it has not been seen flipping during a real freeze, which is why the manual brake stays.
                  • keel withdrawals is a manual switch. attest --suspended halts live entries with no expiry; attest --enabled clears it and still needs a typed yes at a terminal. withdrawals show prints the manual state and the venue's answer per wallet.
                  • DCA catch-up. A DCA buy that was due and did not fill, for any reason, is retried on each later daily cycle until it fills or the rule's next window opens. At most one fill per rule per window, decided by the orders ledger. A resting DCA order blocks retries. Only windows whose buying cycle ran after the rule went live are retried. Backtests and the account sim are unchanged.
                  • keel dca-plan notes that catch-up can add up to one buy per rule to a calendar month; rail 14 still bounds it.

                  Added

                  • macOS notification for every skipped live DCA buy, posted by keel itself ("keel-live: DCA buy skipped"): product, reason, and when it stops retrying. At most once per rule per UTC day, repeating daily while skipped. KEEL_DISABLE_MACOS_NOTIFY silences it. A DCA order stuck pending is reported daily by order id.
                  • Local-only cancel for a stuck pending order with no venue id, behind a typed yes, so a blocked rule can be released. Automatic venue lookup is #1017.

                  Removed

                  • The 7-day withdrawal-attestation TTL and everything built on it: expiry warnings in keel status, keel doctor, notifications, the web console banner, and the weekly runbook step. Rail 22 (cash posture) and rail 14 (subscription) attestations are unchanged.

                  Deploy note

                  The first live cycle after deploy retries every DCA rule whose current window is unfilled. On the windows that opened on the 2026-11-05 bar, that is all seven live rules (about $150), subject to rails 13 and 14. Checked before deploy: the live DB has no resting live BUY rows that would block a rule.

                  Design: docs/superpowers/specs/2026-11-06-rail-17-venue-read-and-dca-catchup-design.md · Plan: docs/superpowers/plans/2026-11-06-rail-17-venue-read-and-dca-catchup.md

                  Supersedes #1013.

                  chore(release): 0.22.0 -- rail 17 reads Coinbase, DCA catch-up, macOS skip alerts (#1018)

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

                  MINOR: rail 17 changes its input and DCA gains catch-up. Schema unchanged (22); no migration.

                  What lands since 0.21.0:

                  • #1016 (supersedes #1013, closes #1010): rail 17 reads Coinbase allow_withdrawals on every live BUY instead of a 7-day attestation; keel withdrawals attest --suspended/--enabled becomes a manual brake with no expiry; a skipped DCA buy retries daily until the rule's next window (one fill per window, the orders ledger decides); every skipped live DCA buy posts a macOS notification; a local-only keel orders cancel for a stuck pending order with no venue id.
                  • #1012: certifi 2026.10.19 for PYSEC-2026-4320.
                  • #1009: the open CodeQL alerts cleared.
                  • Console rework (#13 PR1-4: #956, #959, #963, #966) and its hardening: #970, #972, #975, #977, #978, #984, #986, #987, #999, #1001, #1005, #1008.
                  • Report wording for empty sample sides: #998, #1003, #1007.
                  • Dependencies: #957 (pyjwt 2.15.1, urllib3 2.8.0), uv-build #990-#997.

                  Live impact:

                  • The first live cycle after deploy retries every DCA rule whose current window is unfilled. With the windows that opened on the 2026-11-05 bar, that is all seven live rules once (about $150), subject to rails 13 and 14; the next day buys nothing extra.
                  • Rail 17 now vetoes a live BUY when Coinbase cannot be read. Coinbase is the only adapter that answers.
                  • The live DB has no resting live BUY rows that would block a rule (checked 2026-11-06).

                  Deploy note: install the five release wheels by path into ~/keel and confirm keel versions prints [release]; restart the four com.keel.serve.* daemons so the console runs the new build.