Skip to content

keel v0.17.0

Choose a tag to compare

@github-actions github-actions released this 25 Sep 19:52
· 86 commits to main since this release
f9b4596

Built from f9b4596. Version binds to this hash:
keel --version reports keel 0.17.0+f9b45962408d [release].

Install

Download all wheels from this release into one directory, then install the
keel_trader wheel by path:

pip install --find-links . ./keel_trader-0.17.0-py3-none-any.whl
keel versions

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

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

Configure

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

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

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

Fixes

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

Closes #812.

What changed

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

before: ... signals=3 blocked=0 entered=0 exited=0
after:  ... signals=3 blocked=0 entered=0 exited=0 vetoed=3 (account_dd_breaker_weekly)
  • N counts entries that were evaluated, not placed, and have a non-empty vetoed_by. It is one per entry, not one per rail (the same one-per-entry rule activity.summarise_cycle uses).
  • The overlay's blocked still differs from this line's. For one cycle, overlay blocked = line blocked + vetoed + unplaced entries no rail named. Both docstrings now say so; the old summarise_cycle claim that its counts were the line's is corrected.
  • Rail names come from one parse. The new guards.rail_name replaces three copies: activity._first_clause, doctor's inline split, and this token's.
  • The rail list names each distinct rail once, in first-seen order, using the leading clause of each violation string. The drawdown arithmetic stays in the JSON log. Bare routing-gate tokens such as max_entry_spread are passed through as-is.
  • When nothing was vetoed the line prints vetoed=0 with no parentheses, so a missing token never has to be read as zero.
  • Existing tokens keep their meaning and order. keel-live-run.sh greps only signals=[0-9]+.

Out of scope

  • vetoed= counts entries only. A rail-vetoed exit (for example base_balance refusing a sell) still prints exited=0 with no reason. None has happened in any log yet.
  • An unplaced entry with an empty vetoed_by (a paper no-fill, a declined confirm) is still not shown on the line. It isn't a rail veto, and adding it would mean choosing a separate token.

Verification

  • Wrote 4 tests first and confirmed they failed on the missing token. They compare the exact line or its exact tail, not substrings.
  • Mutants for "an empty vetoed_by counts as a veto", "count per rail", and "no dedupe" were each applied and each killed.
  • The cycle-line tests pair each entry result with its signal, as run_once does, so the FET test pins the real line, signals=3 … vetoed=3. guards.rail_name has its own parametrised test.
  • Full suite: 6684 passed, 3 skipped. ruff check, ruff format --check and mypy are clean.

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

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

What was wrong in #815

  1. Session tokens reached the page. live_peers returned url_for(record), which is http://host:port/?token=…, for every running console, and /api/config carried the list into <option value>. So the paper console's page held the live console's session token, and its own. That breaks security.py ("HttpOnly and must never be written into the page"). On a tunnelled deployment it would also have shipped every local token to the remote viewer.
  2. The dropdown was always empty. el(tag, className, text) has no children form, so el("select", …, ...options) set the select's text to "[object HTMLOptionElement]" and dropped every option.
  3. A console with no record could name the wrong console. A console started from a terminal writes no record. It then got a select with no current entry, and once item 2 was fixed the browser would pre-select the first option, which is another console's name.
  4. Each console's mode was frozen at start-up. It went stale after a config edit, contradicting _auto_trade_mode's rule of re-reading the config on every request.
  5. It turned the session chip into a dropdown, which #704 explicitly forbids. It only got past test_neither_the_chip_nor_the_banner_builds_anything_clickable because select isn't on that test's token list.

The fix

  • No token on the wire. payload.profile_switcher sends {switchable, choices: [{label, href}], display}. Each href is a token-free /switch/<port> on the current console.
  • GET /switch/<port>. This goes through normal admission, then a locality check, then reads the peer's record at click time. It answers 303 to the peer's own ?token= hand-off, which is the route keel open already uses. No page script can read a redirect's Location header.
    • Refusals: no session gives 403; a remote deployment or non-loopback peer gives 403; an unknown, dead or malformed port gives 404; this console's own port redirects to /.
  • One locality rule. security.local_deployment is split out of gated_action_permitted, so the switcher offer and the switch route can't disagree about what "local" means.
  • Service worker. sw.js now sends /switch/ navigations to the network. Otherwise the cached shell would answer them and switching would silently do nothing.
  • A separate control. #console-switcher sits beside the chip. It's a select with a disabled "switch console…" placeholder, listing the other consoles.
    • sessionChip is byte-identical to its pre-#815 code and passes #704's rule unchanged.
    • The new control has its own rule: navigation only, with no button, link, form, input or handler of its own.
    • The deployment card gains a read-only "running consoles" row.
  • Smaller fixes.
    • This console is listed from its own arguments, never from a record.
    • Labels carry no mode, and records no longer store one.
    • serve now calls api._profile_name instead of an inline copy of it.

Verification

  • tests/web/test_profile_switcher.py is rewritten (23 tests). The old version asserted the token was present in the URL.
  • Six mutants were each killed: spreading children into el, dropping the locality check, removing the service-worker guard, listing this console as a choice, offering switching on a remote deployment, and an extra payload field.
  • Full suite: 6711 passed, 3 skipped. ruff check, ruff format --check and mypy are clean.
  • Manual check in Chromium. I ran two detached consoles in a throwaway KEEL_HOME (keel-live and keel-paper) with the service worker controlling the page.
    • The chip shows the profile as plain text, and the switcher lists the other console.
    • Switching paper → live → paper works both ways: each lands authenticated (/api/config 200) with no token left in the URL.
    • /api/config contains no token=.
  • Known limit: a console started after the page loaded appears on the next reload. Following a stale entry returns a 404 that asks you to reload.

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

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

Problem

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

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

Fix

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

Why tests missed it

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

Verification

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

Other changes

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

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

What happened

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

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

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

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

The first cut called this correct

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

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

The obvious fix has a worse trap

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

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

record state meaning action
key absent written before anyone asked refetch once
key present, null the venue was asked and has none honour it, don't re-ask
key present, value known use it

force_refresh, not a delete

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

One test changed rather than being deleted

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

Verification

Mutation, both halves independently:

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

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

What the operator saw

keel is running, and did not admit this browser

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

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

Cause named well. Remedy named nowhere.

Two failures, and the second is the bad one

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

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

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

The change

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

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

keel open --port 8765

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

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

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

Two constraints worth noting

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

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

Verification

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

mutant result
drop the command 3 failed
hardcode the port 1 failed
revert the label 1 failed
tests/web                       -> 972 passed
ruff check keel tests packages  -> All checks passed!
mypy                            -> clean (467 files; pre-existing PyYAML note)
pytest                          -> 6673 passed, 3 skipped

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

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

Summary

Closes #814.

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

Changes

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

Verification

  • uv run pytest tests/web/: 977 passed in 153.91s
  • uv run ruff check keel/web/ tests/web/: All checks passed!
  • uv run mypy keel/web/: Success: no issues found in 9 source files

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

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

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

What's in it since v0.16.0

  • #807: a quote-increment cache record from before quote_increment existed now counts as a cache MISS, not "unknown".
  • #810: the "refused" page now explains how to get the address it asks you to paste. #817 fixes a duplicate const note in that change, which had blanked the whole console on main.
  • #813 (#812): the per-cycle log line now ends with vetoed=N (rail, …), so an entry the rails refused no longer reads as a silent drop.
  • #815 + #816 (#814): a console switcher next to the session chip. /api/config carries no session tokens; /switch/<port> sends the browser into the chosen console's own sign-in, on local deployments only.

After merge

  1. Run Actions → Release with version 0.17.0.
  2. Install into ~/keel using the runbook's "Deploying a new version" steps: back up each database, install the five wheels, run keel migrate, then keel versions and keel status.
  3. Restart the four long-running keel serve consoles, since they keep the build they started with.