Repository navigation
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.
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
dcarule - 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_dayandnext_cadence_day_ts.Dca.detectnow callsis_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/pulsereplaces/api/status,/api/activityand/api/gates. The gate token is on/api/pulseonly, and that is pinned.- Client:
pulseViewandhaltControl, withHALT_CONFIRM.- The gate token is held in
actions.jsand sent byapi.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 --checkpasses 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.pywere 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), thenuvx pip-audit --requirement. Result: No known vulnerabilities found. - I ran
uv sync --frozen --all-extrasin a clean worktree, thenpytest -qwith 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.positionsstores 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_fieldnow 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
ruleslist. - The stop column is drawn only when
stop_rows_presentis non-empty. - Each tranche has a realized-legs disclosure.
- Then Recent orders by keel: the compact
/api/orderstable, 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.
- One table per rule from the report's own
- Removed in lockstep:
- routes
/positionsand/orders, and/api/positions; read_positions,positions_payload,_position_row_payload,_cancel_payload,_CANCEL_STATES, and thecancelkey on order rows;positionsView,ordersView,cancelCell,openCancelHelp,orderDetail;- the
.cancelhelpand.linklikeCSS. /positionsand/ordersjoinedRETIRED_CLIENT_ROUTES, so old links serve the shell and land on Pulse.
- routes
- No close or cancel controls. A structural pin counts buttons and listeners (0) across the Holdings builders.
classify_cancelandkeel orders cancelstay in the CLI. - Operator text that pointed at "the console's Positions view" (
keel positions close,keel doctorledger 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.jsTERMS 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/rulesnow carriesrecords(track records + promotion-gate distances, frombuild_insights_report, the servicekeel insights summarycalls) andclosed_trade_count.recordsis a required keyword onrules_payload. It is not sortable:rulesstays the one sortable collection, and?sort=expectancyis a 400 (ruling 10).- Research: the four
/api/research/*endpoints keep their readers,needs_database=Falseand empty sort surface. Onlyhtml_routemoved to/rules(ruling 9). - Rules view, in page order: ledger, params,
recordsSection(no sort pair; gate cards drawn when agateis 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 asnull, and its section says so. - Removed together:
/insights,/research(added toRETIRED_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_payloadand the journal, curve, series and discretionary-journal builders;chart.jsand its PRECACHE line; main.js's #602 zoom/pan/export block; the.chart*anddata-point-indexCSS. 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)
recordsis a required keyword, notNone → 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.chartactionnever 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_breakoutcards that all had the idh-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 noblockedsaysblocked unknown; it is never shown as 0. gather_timelineaccepts a composite kindkeel, which means{system, attestation}. The report echoeskeelback, andkinds_presentlists it first whenever either member is present.- The CSV export resolves
keelthe same way, so the file holds what the screen shows. - The CLI is unaffected: no
keel timelinecommand exists. The API default is still every kind.
- Cycle rows now name their blocked count:
- Audit & Setup view (
/audit)auditViewreads/api/setupfirst (the primary), then/api/timelineand/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.jsseedsparamsFor("timeline").kind = "keel"once, at boot.- The stopped view's "Set keel up on this machine" link lands here.
- Retired routes
/setup,/timeline,/venues,/balancesand/plansare added toRETIRED_CLIENT_ROUTES, so the server still serves the shell for them.- A new
MOVED_ROUTESmap inmain.jssends/setup,/timelineand/venuesto/audit./balancesand/plansland on Pulse.
- Removed together
/api/balancesand/api/plans, with their readers, payload builders and helpers.keel/commands/plans.py, andkeel/commands/balances.py(see rulings).positions.gather_positions(with_readiness=...), whose only caller wasgather_balances.balancesView,venuesView,venueCard,plansView,claimList,citation.- The
.claimsCSS and thesessionterm indocs.TERMS(it labelledvenueCard's row). - The tests for all of the above.
- Held items from PR1–PR3
gates.pyno longer cites a "modal" that prints the phrase.keel serve --helpand 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.ROUTESnow equalsAPI_ROUTESplus 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.mdandgo-live-runbook.mdare updated.
…truncated — full description in #966.
console: HALT gets its own token; the release token is served nowhere (#968, #969) (#970)
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, underb"keel/web/halt/v1". Only the halt route accepts it, in a header of its own,HALT_HEADER = "X-Keel-Halt"./api/pulseservesdata.halt_token. The oldgatekey is gone. No endpoint servesgates_token./api/gates/resumeand/api/gates/resume-entriesare unchanged. They still need a loopback peer and bind,gates_tokeninX-Keel-Gate, and the phrase.- The client holds
haltTokeninactions.jsand sends it only inX-Keel-Halt. No client module namesX-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.mdandkeel serve --help.
#969 (minor).
MOVED_ROUTESnow coverspositions/orders→holdingsandinsights/research→rules.- Gated actions answer through the new
api.gate_document(key, done). The halt response's message isKILL_ENGAGED_LINE, and the response carries nochangedclaim. - Fixed stale text: the noscript paragraph, "one page of eleven", and the
POLL_MScomment.
Rulings
- Dedicated header
X-Keel-Halt, notX-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
changedkey. 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_tokenreturns 200. - (b) Walk every
API_ROUTESdocument and the CSV export, on a deployment and on an empty machine. No string, key or header carriesgates_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)
runHaltposts{ "<security.HALT_HEADER>": haltToken }. Exactly one module sends it and one module holdsdata.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)
What changes
#961: one bracket verdict, read by three surfaces.
status.bracket_verdictis 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, onDca; - it recorded no real stop.
- its rule kind declares that it never brackets. This is
- 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 theDca.detectstop=0sentinel. The live ledger's tranches 4–6 show this. A check ofinitial_stop is Nonealone would have left all of them warning.status.recorded_stoptreats 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.groupsreplacesrules. It is keyed onpositions.rule_id, and each group's heading iskind · product · rule <id>(for exampledca · 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 theh-hold-<key>element ids unique and free of whitespace, which settles the earlier held H8. - Each row carries
group. The client filters onrow.group === group.keyand prints the service's heading.
Rulings
- 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 rulestakes. - Cost if wrong: the heading carries no budget or cadence; those are on Rules and Pulse.
- 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
- 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.
- 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.
places_bracketis a new class attribute;accumulatesis 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_bracketsforces that to be a deliberate change.
- 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)
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_idis 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 inkeel-paperhourly.dbhas arule_id.
What changed:
paper.track_records_by_rule(repo, kind)splits a kind's paper journal byorders.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.
RuleTrackRecordgainsrule_idandproduct_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 ish-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 printsrule=n/a product=n/aand a one-line note.
#965: an empty side is unknown, not $0.00
- Report.
RuleTrackRecord.avg_winandavg_lossareNonewhen 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.pyandpromotion.pyare untouched:pool_statsmultiplies the zero by a zero count, and_realized_rrreadsavg_loss_r. A test pins that each gate's(passing, blocking_reasons)equalscheck_floorson 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_POSTnow runs its routing in_post()and calls_drain_request_body()in afinally. 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-Lengthcloses 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_bodynow setsbody_consumed, so the closing drain never reads twice after_refusealready 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_objectand answers 400) is now killed.
#958: HALT survives a Pulse that cannot be built
- When the read fails,
/api/pulse's error envelope carrieshalt: {halt_token, resume_command}. The token issecurity.halt_token, never the gates token. The kill switch is read on its own path (api.pulse_halt→pulse.halt_resume_command), not throughgather_pulse. Every other failure, and every pre-admission refusal, carrieshalt: 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:
readingFromforwardshaltoff a failure document. Inpaint, a Pulse failure that carrieshaltgoes torememberHaltToken(the one holder) and then to the newpulseFailedView, which is the samehaltControlfollowed by the error. There is no extra confirm and no phrase. - Tests: with
gather_pulseforced 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 carrieshalt. A refused Pulse carrieshalt: null. Structural client pins cover: Reading literals pairhaltwithattestations, only the failure arm readsdocument_.halt, the order of the paint branches, and thatpulseFailedViewishaltControl({ 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_loggingopenslogging.file(default: the relative pathlogs/keel.log) against the cwd. Many CLI tests load a config that has nologging:section, so this was not one bad test. A bisect plugin foundtests/commands/test_dca_cli.pyas the first writer. - Fix: a new autouse fixture,
tests/conftest.py::_run_in_tmp_path, runs every test from its owntmp_pathand setsKEEL_HOMEto that same folder. - Guard: another autouse fixture,
_the_checkouts_logs_are_untouched, records the files under<repo>/logsand<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_recordfinds its module through__file__.- The alpaca
.envtest unsetsKEEL_HOME.
- The simulate and telemetry tests pass
#973: positions reads the stop through status.recorded_stop
stop_distanceandstop_distance_pctare nowNonefor a DCA tranche that storesinitial_stop"0" (or below).PositionRow.initial_stopkeeps the stored value, because Holdings' paper reading (shown_stop) names that 0.- Corrected the docstrings in
positions.pyanddoctor.py.
#974: scripts/rule_manifest.py apply refuses the params keel rules add refuses
- The params checks in
add_rule_rowmoved 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 includesname. applyruns it on every manifest entry. A refused entry printsREFUSED: 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.jsonstill applies cleanly.
#967: stale references corrected, each pinned by a test that reads the code
_common.record_cash_posture_refutationnamedkeel balancesandkeel brokers list. It now names the real callers that pass no repo; a test resolves everykeel ...it cites through click.server._REFRESH_SECis deleted. Its two citations (events.py,api.py) now point atmain.js'sPOLL_MS. Tests check that every private constant inkeel/web/*.pyis read somewhere in its module, and that any`server._CONST`cited anywhere inkeel/exists.capabilities.render_linesno 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 fromCAPABILITIES. "LAST of four checks" is now "seven". That count is taken from the AST ofdo_POSTand_gated_action(their top-levelif notguards, 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-Siteguard from_postmakes 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)
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(waswin_rate 0.0 < …). The no-R refusal now saysno 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)(wasrr_r 0 < min_rr 1.5). This is decided by countingn_wins_r, and only when the ratio really is that 0. - The same wording applies on the pooled path, with the
pooledprefix. - An all-losses
win_rate 0.0over 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) freezespassingand 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 existingrr=n/a (no losses yet), and the page cell then equals the terminal token exactly. - On the page, "no losses yet" is
neutraland "no wins yet" / "no wins or losses yet" areunknown. 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
valueis""for an undefined ratio. PuttingInfinityon 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 = 60is set on the bound handler bybuild_server, soStreamRequestHandler.setupapplies it to every accepted socket. A refused POST that declaresContent-Length: 100and sends nothing now gets its refusal and is closed; an idle kept-alive connection is dropped instead of parking a thread inreadline. - 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_requestoverride 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_GETis deliberately left byte-for-byte as on main, so CodeQL's existingself.pathdataflow 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_writeopens through the newdb.connect_existing(mode=rwURI, otherwise the same asconnect), which refuses a missing file. A halt that can't be recorded now returns a stated 500 ("The halt could not be recorded … Runkeel 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 onuv_build, update it to allowuv_build0.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 venvcan still use an installed Python 3.14. If no suitable interpreter is installed and automatic downloads are enabled, commands such asuv venvanduvx pythoncan 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, useuv python pin 3.14to record the version in.python-version.Honor
--require-hashesin included constraints files (#22275)Previously, uv ignored
--require-hashesin constraints files included with-cfrom 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-hashesdirective 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_64Python 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.
Updatesuv-buildto 0.13.0Release 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 onuv_build, update it to allowuv_build0.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 venvcan still use an installed Python 3.14. If no suitable interpreter is installed and automatic downloads are enabled, commands such asuv venvanduvx pythoncan 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, useuv python pin 3.14to record the version in.python-version.Honor
--require-hashesin included constraints files (#22275)Previously, uv ignored
--require-hashesin constraints files included with-cfrom 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-hashesdirective 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_64Python 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.
Updatesuv-buildto 0.13.0Release 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 onuv_build, update it to allowuv_build0.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 venvcan still use an installed Python 3.14. If no suitable interpreter is installed and automatic downloads are enabled, commands such asuv venvanduvx pythoncan 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, useuv python pin 3.14to record the version in.python-version.Honor
--require-hashesin included constraints files (#22275)Previously, uv ignored
--require-hashesin constraints files included with-cfrom 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-hashesdirective 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_64Python 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.
Updatesuv-buildto 0.13.0Release 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 onuv_build, update it to allowuv_build0.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 venvcan still use an installed Python 3.14. If no suitable interpreter is installed and automatic downloads are enabled, commands such asuv venvanduvx pythoncan 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, useuv python pin 3.14to record the version in.python-version.Honor
--require-hashesin included constraints files (#22275)Previously, uv ignored
--require-hashesin constraints files included with-cfrom 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-hashesdirective 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_64Python 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.
Updatesuv-buildto 0.13.0Release 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 onuv_build, update it to allowuv_build0.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 venvcan still use an installed Python 3.14. If no suitable interpreter is installed and automatic downloads are enabled, commands such asuv venvanduvx pythoncan 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, useuv python pin 3.14to record the version in.python-version.Honor
--require-hashesin included constraints files (#22275)Previously, uv ignored
--require-hashesin constraints files included with-cfrom 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-hashesdirective 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_64Python 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.
Updatesuv-buildto 0.13.0Release 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 onuv_build, update it to allowuv_build0.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 venvcan still use an installed Python 3.14. If no suitable interpreter is installed and automatic downloads are enabled, commands such asuv venvanduvx pythoncan 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, useuv python pin 3.14to record the version in.python-version.Honor
--require-hashesin included constraints files (#22275)Previously, uv ignored
--require-hashesin constraints files included with-cfrom 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-hashesdirective 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_64Python 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.
Updatesuv-buildto 0.13.0Release 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 onuv_build, update it to allowuv_build0.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 venvcan still use an installed Python 3.14. If no suitable interpreter is installed and automatic downloads are enabled, commands such asuv venvanduvx pythoncan 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, useuv python pin 3.14to record the version in.python-version.Honor
--require-hashesin included constraints files (#22275)Previously, uv ignored
--require-hashesin constraints files included with-cfrom 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-hashesdirective 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_64Python 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.
Updatesuv-buildto 0.13.0Release 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 onuv_build, update it to allowuv_build0.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 venvcan still use an installed Python 3.14. If no suitable interpreter is installed and automatic downloads are enabled, commands such asuv venvanduvx pythoncan 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, useuv python pin 3.14to record the version in.python-version.Honor
--require-hashesin included constraints files (#22275)Previously, uv ignored
--require-hashesin constraints files included with-cfrom 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-hashesdirective 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_64Python 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 backtestused to printstats.profit_factorraw. That meantInfinityfor an all-wins sample, and a0that looks measured for an all-scratch sample (0/0) or for a sample with no closed trade. Since #986,keel insights summaryand 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/0profit_factor=Infinity/0profit_factor=n/a (no losses yet)all scratches 0profit_factor=0profit_factor=n/a (no wins or losses yet)no closed trade 0win_rate=0.00% ... profit_factor=0 expectancy_px=0 max_drawdown_px=0win_rate=n/a ... profit_factor=n/a expectancy_px=n/a max_drawdown_px=n/a(baren/afor every figure, asrender_summaryprints at n=0)all losses 0profit_factor=0profit_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_reasoncalls it, and the newprofit_factor_reason_of(stats)applies it to aBacktestResult. Each side is decided by counting trades through_side_average, asbuild_rule_track_recorddoes.keel/commands/rules.py: at n=0 every unmeasured figure prints baren/a. Otherwise the line printsundefined_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 backtesthas no--json.Tests (TDD)
All new tests are in
tests/commands/test_rules_services.py. They drive the realrun_rule_backtestand 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 simulateedge table's R profit factor still printsinf/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) leavesself.headersa plaindict._admittedrefuses on Host,_refusedrains, and_declared_body_lengthraisedAttributeErroron.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.9does get its headers parsed. With a valid Host and session it was admitted and answered in 0.9 framing (body, no status line). WithConnection: keep-alivethe socket stayed open, so the unframed JSON sat in front of the nextHTTP/1.1 200on the same connection.Fix
KeelHandler.parse_requestruns the stdlib parse first. Ifrequest_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_POSTare untouched, so the CodeQL alerts ondo_GETare not re-attributed.- The halt is unaffected. No browser sends 0.9, the console's
fetchincluded. A versionlessPOSTwas already a stdlib 400.Tests (
tests/web/test_server.py)
- Raw socket,
GET {path}\r\nfor/api/config,/,/api/gates/halt: the reply is a505status line, then a close, andcapfdstderr is empty.- Explicit
HTTP/0.9with 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/pulsecarries #958's{halt_token, resume_command}(api.pulse_halt) when the request carries this session's cookie (KeelHandler._expired_pulse_halt, keyed onApiRoute.halt_on_failure, so it applies to Pulse only). Without the cookie, or with a wrong one, the refusal carrieshalt: null. Nothing else changes: it is still a 403 "Session expired" withdata: 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_GETis untouched.- Client. In
paint'sdata: nullbranch, the HALT arm (pulseFailedView) now comes before the 403 arm. It branches on whetherhaltis 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.jsandmain.jsthat said a reloaded console cannot get the token. The #634 guard pin intest_pwa.pynow allows the} elseprefix and nothing else.Tests (TDD: written first, failed because
haltwasNone)
- 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: nulland 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/pulseafter 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, envelopehalt: null.- Client structure: in
paint, the halt arm comes before the refused arm, which comes before the stopped view. Each arm appears exactly once, andmain.jscontains 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 orrefusal_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 --checkis 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_switchreadtruefrom 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 simreport's edge table printed the engine's rawProfit factor (R):infwhen the R sample had no losing trade, and0.000when it held only scratches or a zero-R win. Those cells now use the wording thatinsights summary, the Rules page andrules backtestalready use (#986/#998):
R sample before after wins only infn/a (no losses yet)scratches only 0.000n/a (no wins or losses yet)zero-R win, no loss 0.000n/a (no losses yet)losses only 0.0000.000(a measured value, unchanged)no R at all n/an/a(unchanged)
insights.profit_factor_r_reason_of(stats)sits next toprofit_factor_reason_of. It calls the same_profit_factor_reasonon the R sample (n_trades - n_excluded_no_risk) and decides each side by countingn_wins_r/n_losses_r, as the issue asks. It never reads a zero.report._profit_factor_r_cellrenders the cell throughundefined_reading, so the wording has one source and is not copied.BacktestResult/profit_factor_r, the promotion gates andpool_statsare unchanged. The test checks that the engine value is stillInfinityafter rendering.Ruling
Change the display rather than keep
inf. The old_r_celldocstring calledinfdeliberate, 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.
infremains 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 checkandruff format --checkare 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.mdnow 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 simedge table'sAvg win (R)/Avg loss (R)cells printed the engine's empty-side0as0.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.0000.000no R at all n/an/aHow
keel/commands/insights.py: newavg_r_reason_of(stats, outcome), besideprofit_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 existingNO_WINS_YET/NO_LOSSES_YET/NO_TRADES_YETconstants. No new strings.keel/sim/report.py: new_avg_r_cell, built the same way as_profit_factor_r_cell: baren/awith no R, thenundefined_reading(reason), otherwise_r_cell(value).keel.commands.insightsis still imported lazily, so the existing subprocess import-isolation test keeps passing.BacktestResult,stats._r_aggregates,promotion.pool_statsand the gates are unchanged.pool_statsstill multiplies by the engine's 0.Tests (TDD)
test_edge_section_words_an_empty_side_average_r_by_counting_the_r_samplefails 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_sidecovers the empty-sample and all-unrisked branch.- Mutation-checked 7 mutants, each confirmed applied with
cmpand 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 --checkand baremypyare 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_GETis 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 everyCLIENT_ROUTES/RETIRED_CLIENT_ROUTESname. 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/pulsecarries{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 nameskeel 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.pyFalse positive (the resolve()+relative_to()containment was real; CodeQL does not modelrelative_toas a sanitizer)resolve_static_assetnow 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_pathsreuses the same listingExisting 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_staticreadFalse positive, same flow Cleared by the change above: the Pathit reads is the listing's own value. No code was moved out ofdo_GET/_serve_staticCovered by the wire-level traversal tests in test_server.py#2 py/http-response-splitting, keel/web/server.pyFalse positive for splitting ( http.serversplits the request line on whitespace, andparsed.pathis 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 Locationisstaticfiles.shell_redirect_target: the member ofshell_request_pathsthe decoded path names, else/. No request byte can reach the headertest_the_token_exchange_redirects_only_to_a_path_the_console_answers(encoded CRLF,%5Cand raw backslash,/api/pulse, unknown name all go to/;/rulesand/statusare kept), andtest_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.pyNear-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 terminalThe refusal names the missing flag and repeats nothing the operator or the .envsuppliedtest_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.pyFalse positive in practice (the input was lowercased first), but the regex was a hand-written tokenizer test_the_shell_ships_no_inline_script_or_stylecollects tags withhtml.parser; same assertions (every script has asrc; 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
securityworkflow'sdependenciesjob fails on main:uv.lockpins certifi 2026.6.17, which has advisory PYSEC-2026-4320 (fixed in 2026.10.19). pip-audit printedcertifi 2026.6.17 PYSEC-2026-4320 2026.10.19and exited 1.Approach
uv lock --upgrade-package certifimoves 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
- 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 --enabledprints aWARNING:line per rule.keel statusprints the same line under the rail-17 line. It does this in live modes only, and the line is also in--json.keel doctoralways emitsattest.dca_cadence, as OK or WARN and never FAIL (R11).- D2, the retry.
Dca.detectstays pure and reads an optional per-cycleDcaWindowLedger. Only live cycles set it (R10), from two newRepositoryreads.
- A window counts as satisfied when a
filledorpendinglive 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_atmeans no retry).- A window that ends unbought is reported on the next cadence day as
context["previous_window_missed_ts"]plus a WARNINGagent.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_failedis 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_SECis unchanged. No config, plist or DB file is touched. There is no CHANGELOG entry (R12);docs/rails/rail-17-withdrawal-attestation.mdgains a section on this instead.Considered and rejected
- Lengthening the TTL. The issue rejects it.
- Counting a window as satisfied when
created_atfalls 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_withdrawalsfromGET /v2/accountsfor 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 observedtrue; it has not been seen flipping during a real freeze, which is why the manual brake stays.keel withdrawalsis a manual switch.attest --suspendedhalts live entries with no expiry;attest --enabledclears it and still needs a typedyesat a terminal.withdrawals showprints 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-plannotes 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_NOTIFYsilences it. A DCA order stuckpendingis 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.mdSupersedes #1013.
chore(release): 0.22.0 -- rail 17 reads Coinbase, DCA catch-up, macOS skip alerts (#1018)
Version bump only: every workspace
pyproject.toml0.21.0 -> 0.22.0 (and the==sibling pins) plus the refresheduv.lock, the same nine files every bump moves (tests/test_packaging.pyandtests/test_version.pygreen locally). Merge this, then run the Release workflow with0.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_withdrawalson every live BUY instead of a 7-day attestation;keel withdrawals attest --suspended/--enabledbecomes 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-onlykeel orders cancelfor 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
~/keeland confirmkeel versionsprints[release]; restart the fourcom.keel.serve.*daemons so the console runs the new build.