Repository navigation
keel v0.17.0
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.
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 ruleactivity.summarise_cycleuses). - The overlay's
blockedstill differs from this line's. For one cycle, overlayblocked= lineblocked+vetoed+ unplaced entries no rail named. Both docstrings now say so; the oldsummarise_cycleclaim that its counts were the line's is corrected. - Rail names come from one parse. The new
guards.rail_namereplaces 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_spreadare passed through as-is. - When nothing was vetoed the line prints
vetoed=0with no parentheses, so a missing token never has to be read as zero. - Existing tokens keep their meaning and order.
keel-live-run.shgreps onlysignals=[0-9]+.
Out of scope
vetoed=counts entries only. A rail-vetoed exit (for examplebase_balancerefusing a sell) still printsexited=0with 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_bycounts 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_oncedoes, so the FET test pins the real line,signals=3 … vetoed=3.guards.rail_namehas its own parametrised test. - Full suite: 6684 passed, 3 skipped.
ruff check,ruff format --checkandmypyare 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
- Session tokens reached the page.
live_peersreturnedurl_for(record), which ishttp://host:port/?token=…, for every running console, and/api/configcarried the list into<option value>. So the paper console's page held the live console's session token, and its own. That breakssecurity.py("HttpOnlyand must never be written into the page"). On a tunnelled deployment it would also have shipped every local token to the remote viewer. - The dropdown was always empty.
el(tag, className, text)has no children form, soel("select", …, ...options)set the select's text to"[object HTMLOptionElement]"and dropped every option. - 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.
- 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. - 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_clickablebecauseselectisn't on that test's token list.
The fix
- No token on the wire.
payload.profile_switchersends{switchable, choices: [{label, href}], display}. Eachhrefis 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 answers303to the peer's own?token=hand-off, which is the routekeel openalready uses. No page script can read a redirect'sLocationheader.- 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
/.
- 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_deploymentis split out ofgated_action_permitted, so the switcher offer and the switch route can't disagree about what "local" means. - Service worker.
sw.jsnow sends/switch/navigations to the network. Otherwise the cached shell would answer them and switching would silently do nothing. - A separate control.
#console-switchersits beside the chip. It's a select with a disabled "switch console…" placeholder, listing the other consoles. - Smaller fixes.
- This console is listed from its own arguments, never from a record.
- Labels carry no mode, and records no longer store one.
servenow callsapi._profile_nameinstead of an inline copy of it.
Verification
tests/web/test_profile_switcher.pyis 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 --checkandmypyare 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/config200) with no token left in the URL. /api/configcontains notoken=.
- 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
notetohowNote. - Added a test:
test_no_module_declares_a_name_twice_in_one_scoperuns over every module instatic/js/. It scans for aconst/let/classname declared twice in one block scope, after_code_onlystrips strings and comments. test_the_redeclaration_scan_can_failpins 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.jswith['note']and passed on the other nine modules before the fix. node --checkparses the fixedrender.js.tests/web: 987 passed.ruffis 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_incrementkey 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 8765It 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): Addedprofileandmodeto serving runtime record; addedlive_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.91suv 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_incrementexisted 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 notein that change, which had blanked the whole console onmain. - #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/configcarries no session tokens;/switch/<port>sends the browser into the chosen console's own sign-in, on local deployments only.
After merge
- Run Actions → Release with version
0.17.0. - Install into
~/keelusing the runbook's "Deploying a new version" steps: back up each database, install the five wheels, runkeel migrate, thenkeel versionsandkeel status. - Restart the four long-running
keel serveconsoles, since they keep the build they started with.