keel v0.5.3
Built from a2a1ea6. Version binds to this hash:
keel --version reports keel 0.5.3+a2a1ea64e17b [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.5.3-py3-none-any.whl
keel --version
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
feat(tui): reach the asset-admission gate from the dashboard (screen / propose / discover) (#176)
The allowlist-admission workflow — the halal asset gate — was CLI-only. This puts it on the operator dashboard: three new overlays, s screen, p propose, d discover, alongside the existing i insights.
Nothing here admits, attests, or trades. keel assets attest stays deliberately CLI-only: it is the one step in this gate that rests on human judgment rather than code, and it must not be a keystroke.
The overlays
sscreen — the current allowlist's admission verdicts, with the reason for each. OFFLINE, DB reads only.ppropose — screens the newest*.jsonshortlist inconfig.proposals_dir(new setting, default~/keel/proposals,~expanded at use rather than baked into aConfig). OFFLINE. A missing directory, absent shortlist, unreadable file, bad JSON or malformed proposal each render as a calm, actionable explanation — never an error that takes the dashboard down. Invalid candidate entries are reported asINVALID, never dropped.ddiscover — proposes NEW candidates from the venue's own product list.
Each reuses keel/proposer.py and the existing _screen_product gate verbatim rather than growing a second rendering or admission path.
d is gated harder than f is
Discover is the second deliberate network exception in a dashboard whose contract is read-only and offline. Pressing d makes no call at all — it opens ARMED, rendering an explanation of what running it will do and that it is a live venue call. Only Enter, pressed inside the overlay, makes the one list_products request. The result is then HELD: later polls repaint it without re-fetching, and closing discards it so reopening is armed again. It is bounded by a timeout, because the operator is blocked on it with the screen frozen — a hung venue becomes a retryable discover failed: line, not a dashboard whose only exit is Ctrl-C.
Two tests pin this by driving the real loop: zero list_products calls across d + several polls + close, and exactly one per Enter with no re-fetch on subsequent polls.
The correctness fix this uncovered
DATA_DERIVED_FAILURES claimed to hold every failure class "DOWNSTREAM of having no cached history" but omitted history itself. So a product with zero cached candles was reported as history: 0 daily bars, need 1460 — which reads as "this asset is too young" when the truth is "we have never fetched it". A candidate never fetched was indistinguishable, by its failure list alone, from one that genuinely is too young. Since screening an unfetched asset is the normal first step of admitting one, this was the common case, not the edge case.
The split and its explanation now live once, in screen.py beside the tag set that decides them:
split_failures(facts, result) -> (about_the_asset, about_our_cache)
missing_history_lines(product_id, not_assessable) -> list[str]
assets holdings --screen, assets propose, assets screen and the new TUI overlay all route through them, replacing three independent copies of the same logic. A zero-bar product now says "no local history — run keel fetch --products X first" and names what is unassessable until then, instead of asserting something false about the asset.
The suppression fires at exactly zero bars, so an asset with some history that still falls short is still reported as genuinely too young — pinned by counterpart tests on every surface, so the fix cannot decay into a blanket silencer.
keel assets screen was initially left out of that migration and a review caught it. It is the sibling of the new s overlay — both screen the same product set through the same gate — so leaving only one able to explain an empty cache would have handed an operator two different stories about the same allowlist depending on which surface they looked at.
Also
tui_cmd's docstring claimed the TUI "never touches the network except when explicitly asked (f)", whilerun_livehas all along made an automatic ~30sget_accountsbalance read — whichrun_live's own docstring acknowledged. The two contradicted each other before this branch. The docstring now enumerates all three network touches honestly.run_live's help and insights branches each hand-rolled an identical scroll chain. Rather than copy it three more times, both now share one pure_scroll_offset, behaviour unchanged.
Verification
ruff clean, mypy clean, 2062 tests passing (1976 on main, +86).
Beyond running the suite, the two constraints that carry the safety weight were mutation-tested: removing history from the tag set fails 5 tests, and making discover fetch on open or on poll fails 4. Both were confirmed to fail for the right reason before the fix existed.
fix(admission): close the non-UTF-8 shortlist hole and pin the claims #176 made (#177)
An independent adversarial review of #176 returned SHIP WITH CHANGES: network gating and the read-only guarantees held up (34 socket-disabled checks, DB bytes identical, attest absent), with ten findings to fix. This is all ten, TDD — every test below was seen failing for the right reason before the fix, and every coverage-only test was verified to die under a deliberate mutation.
The two real bugs
F1 — build_propose_view broke its own "Never raises" contract. source.read_text() was guarded by except OSError, but UnicodeDecodeError subclasses ValueError (issubclass(UnicodeDecodeError, OSError) is False). A UTF-16LE+BOM shortlist — valid JSON, and what a scout run on Windows writes — escaped both handlers and propagated out of a function whose docstring promises "Never raises. Every failure mode is FAIL-SOFT". Observed: the propose overlay repainting propose read failed: 'utf-8' codec can't decode byte 0xff… every poll forever, naming no file and no next step, surviving only on run_live's broad except Exception; and keel assets propose --from exiting on a raw traceback on the same input. Both paths now take the existing unreadable fail-soft branch, which names the file.
The two comments this falsified — _do_propose_view's docstring and the identical inline comment in run_live's propose branch, both claiming an exception "can only come from open_state()… never from the shortlist read itself" — are corrected. Note they were not merely stale: build_propose_view screens every parsed candidate after parsing, so a locked DB surfaces there too. The handler is load-bearing for both, and now says which.
F9 — assets propose --json told a different story from every human surface. It emitted sc.result.failures raw, so at zero cached bars the payload carried history: 0 daily bars < 1460 required unflagged, while render_proposal_report, assets holdings --screen, assets screen and the TUI overlays all suppress that exact line and print the MISSING-DATA explanation. That is precisely the drift split_failures was created to end, surviving in the one surface a script reads and cannot argue with. --json now applies the same split and makes it explicit: failures (verdicts about the asset), not_assessable (suppressed, never dropped), missing_history.
F2 — the single-admission-path property, tested at the wiring point
Design constraint 4 ("every candidate routes through _screen_product, so nothing drifts onto a laxer gate") was convention-only exactly where it is wired. Replacing _screen_product with an always-ADMIT stub in _do_screen_report or _do_propose_view left all 2062 tests green — the existing overlay tests only assert a title paints and Esc closes, never a verdict.
Two run_live tests now seed an asset with ample cached history and liquidity but no attestation, so the only thing that can reject it is screen_asset failing closed on attestation=None — something only the real gate does. Both assert the overlay paints REJECT plus attestation: MISSING, and assert the absence of ✗ history/✗ liquidity so the REJECT provably comes from the shariah criterion rather than an incidental data shortfall any stub would also produce.
Both mutations verified dead. The _do_screen_report one was also run against the full suite pre-fix: it failed nothing.
Honesty fixes — comments asserting more than the code can know
- F3
split_failuresclaimed a shallow-but-non-empty cache means the asset "really is too young".MarketFactscarries a bar count and no first-bar timestamp, so it cannot distinguish that fromkeel fetch --years 2, an aborted fetch, or a venue not serving the full window (fetchprints a note about exactly this). Reworded to say plainly that the two are indistinguishable here, why the gate still fails closed on the ambiguity, and what the operator should check first (keel fetch --products <id> --years 5, then re-screen). Not re-engineered. - F5
missing_history_linessaid a zero-bar asset "is not too young, we have simply never fetched candles for it" — the same overclaim in the opposite direction, since at zero bars a three-day-old listing and a three-year-old one are literally the same input. It now refuses to rule either way. - F10 "the SECOND deliberate network exception" appeared 3× — module docstring, help Safety notes, and the operator-facing ARMED overlay — forgetting the ~30s live-balance refresh that has been a real
get_accountscall since v3. It is the THIRD of exactly three, astui_cmd's docstring already said correctly. All three corrected and pinned by a parametrized test across all four surfaces. The help's "Live balance" section never said the refresh is a live venue call at all — the omission that made "second" read as plausible three sections later — so it now says so, and says it is a read that places no orders. - F4
run_livesaid "Seven modes"; there are six.
Coverage for correct-but-untested code
- F7
config.proposals_dirresolution was never executed by the suite (every test passeddirectory=), so hardcoding a path left 2062 tests green — and the default is~/keel/proposals, inside the live deployment root. Two tests withHOMEredirected attmp_path: one pins that a non-defaultproposals_diris honoured, one that the default resolves to<home>/keel/proposalsexpanded. Both die under a hardcoded path; both die under a dropped.expanduser(). - F8
_DISCOVER_TIMEOUT_SEChad no test. Pinned per-call, not globally, so collapsing it into_BALANCE_TIMEOUT_SECis caught too. Dies under both mutations. - F5 (test half) the semantic sentence, plus the guarantee that these lines never restate a suppressed failure verbatim (the call-site proxy
"✗ history" not in textwould miss a leak, since a leak carries no✗).
F6 — a test renamed to what it actually tests
test_run_live_discover_closing_discards_the_held_result is now test_run_live_discover_reopening_after_a_run_is_armed_not_stale.
I took the rename rather than the "pin the close branch specifically" option, and verified the reason: deleting the close-branch clear alone leaves the test green; deleting both clears kills it. Nothing observable from outside run_live can separate them, because mode only ever becomes discover via the normal-mode d branch that also clears — there is no route into the overlay that bypasses it. Keeping both clears is deliberate defence in depth, so the honest fix is the name plus a docstring that states the disjunction and names both lines.
One thing I'd flag back
F5 was written as a coverage finding, but the sentence it points at is the same overclaim F3 flags, one step further along: "it is not too young" asserts a fact about the asset at exactly the bar count where we have zero evidence about the asset. I reworded it rather than only pinning it. Called out here in case the intent was to pin the existing wording as-is.
Gates
ruff check keel tests packages clean · mypy keel packages clean (95 files) · pytest -q 2080 passed (2062 baseline + 18 new).
feat(tui): show the patch segment in the dashboard header (#178)
The TUI header now renders keel v0.5.2 instead of keel v0.5.
Why
_short_version truncated to v<major>.<minor>, so 0.5.0, 0.5.1 and 0.5.2 all rendered identically as v0.5. That makes the header unable to answer the one question an operator glances at it for — is this box running the build I just shipped, or the one before it? Three releases went out in the 0.5 line today alone.
The detail that matters
Build metadata rides on the patch segment. A released version is 0.5.2+79f35b9e73d5, so parts[2] is 2+79f35b9e73d5 and a naive .isdigit() check returns False. Taking it verbatim would print a commit hash into a header line budgeted for a version; rejecting it would drop the patch on exactly the shape a release build emits — the common case, not an edge case. The patch is split on + and the numeric head kept.
Two further degradations, each keeping the most it can still vouch for rather than falling all the way back to v?:
| Input | Renders | Why |
|---|---|---|
0.5.2 |
v0.5.2 |
the point of the change |
0.5.2+79f35b9e73d5 |
v0.5.2 |
build metadata stripped — what a release actually looks like |
2.0 |
v2.0 |
no patch segment; show what exists rather than inventing a .0 |
0.5.2rc1 |
v0.5 |
non-numeric patch; keep the two segments that did parse |
unknown / 1 / `` |
v? |
reserved for an unparseable major/minor |
Verification
Test-first: the five patch-expecting cases failed before the change and pass after. 2083 tests pass (+3 new parametrize cases), ruff clean, mypy clean across 95 files.
Not visible on the live deployment until a release — it is pinned to 0.5.2+79f35b9e73d5, which predates this.
chore: bump to 0.5.3 for the asset-admission work (#179)
Version bump only — no code changes. Ships #176, #177 and #178.
Patch: no schema change, no rail change, nothing that alters what the agent trades or when.
Why cut this now rather than wait
#176 fixed a reporting bug that is live on the deployment right now. history was missing from DATA_DERIVED_FAILURES — the tag set marking a failure as downstream of an empty cache — so keel assets propose and keel assets holdings --screen printed:
This is a MISSING-DATA verdict, not a verdict about the asset.
✗ history: 0 daily bars < 1460 required
An asset nobody had ever fetched was reported as too young to trade. The fix is at the tag set, and three independently drifting copies of the split were consolidated into one.
Also included
- The TUI can reach the admission gate —
screen/propose/discoverin the menu.proposeandscreenare fully offline;discoveris network-gated behind an explicit keypress likef. None can attest, admit, or trade —atteststays CLI-only, since it is the human judgment the gate rests on. An independent review verified this with sockets hard-disabled: zero network calls on open, poll or refresh, and the DB byte-identical across every path. - #177 —
UnicodeDecodeErrorsubclassesValueError, notOSError, so a non-UTF-8 shortlist escapedexcept OSErrorand brokebuild_propose_view's documented never-raises contract, wedging the propose overlay on every repaint. Plus tests pinning that both TUI overlays route through_screen_product, which mutation-testing showed was convention-only. - #178 — the header shows the patch segment, so 0.5.1 and 0.5.3 no longer render identically as
v0.5.
uv.lock relocked in the same commit, per 0.5.0 through 0.5.2 — leaving it behind is what made 0.4.0 fail its self-identification check. Verified with uv sync --frozen, which accepted the lock and rewrote nothing beyond the five pyproject.toml bumps.
Gate: 2083 tests pass, ruff clean.