Repository navigation
keel v0.12.1
Built from f25b6af. Version binds to this hash:
keel --version reports keel 0.12.1+f25b6afe16b5 [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.12.1-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.
Desktop app (macOS / Windows)
Apple's certificate is $99/yr and Azure Trusted Signing is ~$120/yr, and
keel cannot currently afford either. There is no cheaper tier and no free
open-source option on either platform. (Windows is worse value than it looks:
since 2024 even an EV certificate no longer grants an instant SmartScreen
pass — reputation is earned from download volume over time.)
Prefer no warning at all? Install from the wheels above instead — nothing
is downloaded as an application, so nothing objects. Needs a terminal and
Python 3.11+.
Full explanation, including how to check what you downloaded:
https://github.com/CodeGateSoftware/keel/blob/main/docs/desktop-install.md
macOS — the first open is refused. Open System Settings → Privacy &
Security, scroll to the message about keel, and choose Open Anyway.
You will only do this once.
Windows — SmartScreen shows "Windows protected your PC". Choose More
info → Run anyway.
Verify what you downloaded before you run it. Every artifact carries a
GitHub build attestation binding it to this repository, this workflow and this
commit — which is the question a code-signing certificate answers too:
gh attestation verify <file> --repo CodeGateSoftware/keel
SHA256SUMS.txt is attached as well. If either check fails, do not run it.
The app opens keel in your browser; there is no terminal involved. Your
config, database and credentials live in your user Application Support /
LocalAppData folder and are never touched by an update.
Other changes
docs(analysis-feed): the Analyzer proposes, the gate decides (#579)
What this is
Ibn Khaldun Analyzer's PRD names keel as its reference subscriber and has already shaped two of its own decisions around keel's constraints: SSE exists as a first-class transport so keel needs no new dependency, and §3.8.4 binds the platform never to emit an order, a size, a price, or an instruction to trade. This is keel's side of that contract — a PRD and an SRD, no code.
In one sentence: the feed proposes assets for human attestation; keel's existing deterministic gate decides; nothing new touches admission, sizing, or order placement.
The finding that decides whether any of this happens
proposer.Candidate requires asset, rationale, and a non-empty sources[]. On the Analyzer's dual payload, rationale comes from prose.conclusion and is available on the free tier — but asset comes from signal.entities[kind="ticker"] and sources[] from signal.sources. Both machine-readable fields live in the signal block, and signal is the Desk-tier boundary. keel cannot extract a symbol or a citation list from prose without an LLM, which is a dependency it does not have and will not add.
So the integration requires the Desk tier or it does not work at all. That is a purchasing decision, it gates the entire milestone, and it belongs at the top of the PRD rather than being discovered halfway through Phase A.
The case against, stated before the case for
keel's binding constraint is cost: no shipped rule family is net-positive at the taker fee actually paid, and the viable parameter/fee intersection is empty. A paid subscription does not move that by a basis point. It also cannot pay for itself by improving entries or sizing without becoming a trading signal — which the non-goals forbid outright.
What it buys is evidence and reach, not edge: a wider, cited view of which assets are in play, arriving as proposals a human still has to attest. If after a measured period it has not improved the breadth or quality of attested candidates, it should be cancelled. #578 makes that a dated decision with a published result either way, including a negative one.
And #570 says: do not buy Desk blind. The Analyzer ingests SEC EDGAR, the Federal Reserve, BLS/BEA — macro and equities sources. How much of its output names crypto assets at all is unmeasured. Read a week of free-tier prose first; closing this milestone unbuilt is an acceptable outcome.
The architecture, and the one property it is chosen for
GET /v1/stream/sse ──▶ keel/analysis/feed.py (urllib, stdlib only, bounded)
▼ AnalysisFrame
keel/analysis/adapter.py (pure: frame → Candidate)
▼ shortlist.json
keel assets propose --from … ← UNCHANGED
▼ screen_fn(repo, product, quote)
a report a human reads. Admits nothing.
build_proposal_report injects the gate as screen_fn(repo, product, quote) — three arguments, none of which is the rationale, with a docstring that states the intent. The feed inherits that asymmetry unchanged: it writes the same shortlist file assets propose --from already parses, and that path is untouched. The gate's input never widens because its prose is better sourced.
The boundary is a file on purpose — inspectable, diffable, keepable.
What we read is a contract, not documentation
The frame also carries direction, horizon, confidence, and dimension_scores — exactly the shape of a trading signal, and exactly what a future reader would be tempted to route into sizing or timing. keel reads none of them. A field keel does not read is a field keel cannot misuse.
shariah_hypothesis is likewise left unset. Nothing in the Analyzer's output is a qualified source, and a hypothesis sourced from it would be a ruling wearing a citation.
Staleness is keel's judgement, not the server's — the counterpart is explicit about that, which is why every frame carries emitted_at. A frame with a missing or unparseable one is dropped, not defaulted to "now": the failure mode of guessing is treating an old assessment as current.
Config lands under analysis_feed:, never subscription:
That key is rail 14's per-venue fee-tier attestation, enforced against live trading. An operator reading subscription: lapsed must not have to work out which subscription is lapsed, because one of them stops trading and the other does not.
The pins that matter are the AST scans
Unit tests prove the adapter maps fields correctly today. The scans prove a later change cannot wire an assessment into admission or into a cycle: agent.run_once cannot reach the feed, and nothing under keel/analysis/ may attest, write the allowlist, or place an order. #576 carries all seven, each mutation-verified.
One more, worth naming: the feed appears in no keel/capabilities.py row, because it increases nothing. If a future change gives it the power to attest, promote, arm, spend or place, that change must add the row and the gate — and the pin asserting the absence is what forces the conversation.
Milestone
Phase 14 — Analysis feed (Ibn Khaldun Analyzer)
| A1 | #570 — decide the tier; measure crypto relevance before buying Desk |
| A2 | #571 — analysis_feed: config and credential |
| B1 | #572 — bounded SSE client on urllib |
| B2 | #573 — the frame→Candidate adapter |
| B3 | #574 — keel analysis fetch |
| B4 | #575 — entitlement visibility |
| C1 | #576 — the safety pins |
| C2 | #577 — the operator guide |
| C3 | #578 — the keep-paying decision |
Docs only — no code, no tests, no runtime change.
feat(web): the browser UI wears keeltrading.com's identity (#594)
Closes #593.
The v0.12 web UI shipped its own palette and its own mark; a user who met keeltrading.com and then opened the app met two products. This PR puts the site's identity on the app: same paper, same teal, same ship. Palette and mark only — no layout, no components, no JS (no color literal lives in JS; the only hex-adjacent strings there are issue references).
The token mapping
Source of truth: ../keeltrading.com/src/styles/global.css (READ-ONLY sibling repo; CI cannot read it, which is why the copy is pinned by test — see below).
site token (global.css) |
site value | → app token (keel.css) |
light | dark |
|---|---|---|---|---|
--bg |
#f8f7f3 |
--bg |
#f8f7f3 ✅ copy |
#0f171d (site dark --bg) ✅ copy |
--surface |
#fffefb |
--card |
#fffefb ✅ copy |
#162128 ✅ copy |
--ink |
#1d2833 |
--fg |
#1d2833 ✅ copy |
#e9e7e0 ✅ copy |
--muted |
#4b5b69 |
--muted |
#4b5b69 ✅ copy |
#a4b4be ✅ copy |
--border |
#d9d4c8 |
--line |
#d9d4c8 ✅ copy |
#2b3a44 ✅ copy |
--accent |
#0c5d52 |
--accent |
#0c5d52 ✅ copy |
#57c5af ✅ copy |
--gold-4 / dark --gold-3 |
#7a5c15 / #d4af37 |
--warn |
#7a5c15 |
#d4af37 |
| — (no counterpart) | --bad |
#a63e26 (was #7b2915) |
#e07a6a (kept) |
|
| — (no counterpart) | --good |
#1c5c47 (was #1f5f4f) |
#7cd69f (was #83d3b2) |
|
| — (no counterpart) | --control-line |
#84817c (kept) |
#606f7a (was #706d66) |
Site tokens with no app consumer (--surface-2, --accent-strong, --accent-contrast, the notice set, --code-bg, the gold-1/2 remainder) have no app counterpart to map onto — the app has no notice boxes or code blocks in this token set, and inventing consumers is out of scope.
The semantic re-tunes (tokens with no site counterpart)
The site is a brochure, not a console: it has no warn/bad/good. Those keep #532's semantics — warn is a real difference where nothing is broken, bad is loss, good is gain — and are re-tuned onto the site's paper, each documented in the css beside its declaration:
- light
--warn: #7a5c15— the site's own--gold-4. The gold set is the site's warm-ink family, and gold-4's luminance (0.1185) sits closest to the amber the token already wore (#8a5a00, 0.1272): same hue, same job, now the site's literal. AA 5.81:1 / 6.18:1. - dark
--warn: #d4af37— the site's dark--gold-3. The closer sibling of the light choice (dark--gold-1#c9a43a) lands at 6.91:1 on--card, a hair under this palette's AAA working standard; gold-3 is the same ink a step lighter: AAA 8.60:1 / 7.79:1. - light
--bad: #a63e26— the old brick lightened within its hue. Against the old near-black ink it cleared every floor; against the site's lighter#1d2833it sat at 1.55:1 from body text, under the 1.7:1 floor. Lightening buys distance from--fg(2.38:1) and from--good(luminance delta 0.0334 over the 0.025 floor). AA at 5.87:1 — the one grade this PR lowers, see below. - dark
--bad: #e07a6a— kept; clears every dark floor against the site's slate exactly as it cleared them against the old warm black (AA 6.17:1 / 5.58:1, 2.37:1 from--fg, 0.2407 from--good). - light
--good: #1c5c47— the old green darkened a step and leaned away from blue: the site's accent is a teal, and a gain that read as a second brand teal would repeat the link-versus-gain confusion #532 split these tokens to end. AAA 7.33:1 / 7.79:1. - dark
--good: #7cd69f— the old mint leaned green at the same luminance (0.5488 vs 0.5463), for the same reason: the site's dark accent (#57c5af) is a mint teal. AAA 10.32:1 / 9.34:1. - light
--control-line: #84817c— kept: a warm neutral already sitting between the site's border and its muted ink, clearing the 3:1 boundary minimum at 3.62:1. - dark
--control-line: #606f7a— the site's dark border (#2b3a44, 1.54:1 — fine for decorative rules, not for the only cue marking an input) at the lightness the 3:1 minimum demands; the hue moved from warm gray to slate because the page under it did.
Contrast results (the WCAG gate)
Every pair in tests/web/test_palette_contrast.py passes as-is; no threshold was weakened. All measured ratios:
| pair | light | dark | floor |
|---|---|---|---|
| fg / bg | 13.96 AAA | 14.62 AAA | AA |
| muted / bg · card | 6.53 · 6.94 AA | 8.49 · 7.68 AAA | AA |
| accent / bg · card | 7.25 · 7.70 AAA | 8.63 · 7.81 AAA | AA |
| warn / bg · card | 5.81 · 6.18 AA | 8.60 · 7.79 AAA | AA |
| bad / bg · card | 5.87 · 6.24 AA | 6.17 · 5.58 AA | AA |
| good / bg · card | 7.33 · 7.79 AAA | 10.32 · 9.34 AAA | AA |
| control-line / bg | 3.62 | 3.49 | 3.0 (1.4.11) |
| line / bg (exempt divider) | 1.38 | 1.54 | must stay < 3.0 |
| good↔bad luminance delta | 0.0334 | 0.2407 | 0.025 / 0.2 |
| min signal-vs-fg | 1.90 (good) | 1.42 (good) | 1.7 / 1.4 |
One grade-floor entry is lowered, with the reason stated in _GRADE_FLOOR: light --bad AAA→AA. The site's ink is lighter than the near-black it replaces, which narrows the band a light signal colour can occupy (AAA against the paper, 1.7:1 away from body ink, 0.025 luminance from its opposite) to 0.0207 — too narrow for good and bad both. Holding --bad at AAA meant moving it back toward --good (the exact collision #532 exists to fix) or toward --fg (reading as a neutral row in greyscale); AA at 5.87:1 with record separation from both is the honest trade, recorded per the table's own protocol ("must not lower without a stated reason and an updated entry"). Every floor value in the suite is untouched.
Pins that legitimately moved were re-measured, not deleted: fg/bg exact pins re-based to 13.96:1 / 14.62:1; --line pins re-based to the site's border literals (#d9d4c8 / #2b3a44), still asserting the < 3:0 decorative exemption holds; the measured minimums quoted in _MIN_SIGNAL_FG_RATIO / _MIN_GOOD_BAD_LUMINANCE_DELTA comments updated.
New identity pin (tests/web/test_client_assets.py::test_the_palette_wears_keeltrading_coms_identity, written red-first): keel.css's light --accent and --bg must equal #0c5d52 / #f8f7f3, with the docstring naming keeltrading.com's src/styles/global.css as the upstream CI cannot read — the identity cannot silently drift.
The mark on every icon
keel.svg's artwork is replaced by the site's ship-mark. The structural reality of this repo: icons are generated by scripts/build_icons.py and test_pwa.py requires the committed PNGs to be byte-identical to a fresh render — so the artwork change lives in that script's geometry (where a diff is readable), not in hand-made binaries.
- Geometry — transcribed from the site's
public/favicon.svgin its own 24-unit design space: mast, two stays, hull. The hull's two cubic beziers are sampled at t = 0, .125, …, 1 (chord deviation 0.099% of the tile ≈ 0.5px at 512; four samples per side read as facets at 512 and were doubled). - Colours — the site's
--accentteal#0c5d52with the stroke in the site's paper#f8f7f3, exactly as the site's favicon strokes it (not pure white). - Round caps/joins — the rasteriser is polygon-only, so they become tangent-edged octagons at every stroke vertex. Butt-capped chord joins left 3px wedges at the hull's ~18° turns — found by sampling the render against true round-join coverage at 2048px, not by eyeballing.
- Two deliberate adaptations, both technical: the SVG keeps the site's rounded tile corners (
rx) and true round caps (a favicon sits on an unknown tab background — it is the site's favicon, redrawn from the same numbers); the rasters stay full-bleed and opaque per #538's design, and the maskable tile must bleed to the launcher's crop edge regardless. - Tooling — the repo's own deterministic generator (
uv run python -m scripts.build_icons), not an external rasteriser:--checkbyte-identity is the review mechanism for four binaries. The ship is ~16 small shapes where thekwas three, so coverage now accumulates per polygon over its bounding box (same sample grid, same even-odd decision — identical bytes, ~1.5s for all four icons). - Manifest / shell —
theme_color#0c5d52,background_color#f8f7f3; index.html's theme-color meta matches (the pwa test holds the two in agreement). The favicon and apple-touch-icon links already point at the regenerated files.
Test delta
- New:
test_the_palette_wears_keeltrading_coms_identity(identity pin, red-first). - Re-based:
test_fg_on_bg_contrast_has_not_regressed(13.96/14.62),test_decorative_dividers_stay_exempt_and_unchanged(site border literals),_GRADE_FLOORlightbadAA with stated reason, measured-minimum comments. - Untouched: every threshold/floor value; the pwa/icon suite passes unchanged (icons regenerate byte-identically from the committed geometry).
Gates
uv run pytest -q → 4421 passed, 3 skipped. uv run ruff check keel tests scripts → clean. uv run mypy → clean (350 files). uv run python -m scripts.build_icons --check → 4 icons match.
chore(release): 0.12.1 (#596)
What this is
The version-bump PR for 0.12.1, following #591's precedent exactly: one commit, every version and every == pin moves together, uv.lock moves with them. Per docs/RELEASING.md, merging this (not the workflow) is what sets the version — the release workflow then refuses to change it.
Patch, not a minor: since v0.12.0 the only user-facing change is #594, and #592 ships no runtime code. No new interfaces, so the patch is right.
What ships in 0.12.1
Enumerated from git log --oneline v0.12.0..HEAD:
- #594 — the browser UI wears keeltrading.com's identity (#593): the UI wears keeltrading.com's palette; the keeltrading.com mark lands on every icon; the hull closes and the caps land — the mark is the site's, measured.
- #592 — the release-workflow attestation fix: the Windows attestation subjects are newline-separated, not space-separated (the space-separated form is one path to the action, and the v0.12.0 dispatch failed at exactly that step). Workflow-only; no runtime code ships.
- Everything else since v0.12.0 is docs (#579's analysis-feed decision record).
Files bumped
pyproject.toml—version0.12.0 → 0.12.1, plus the three sibling pins (keel-core,keel-broker-api,keel-broker-coinbase)packages/{keel-core,keel-broker-api,keel-broker-coinbase,keel-broker-alpaca,keel-broker-fake,keel-broker-robinhood,keel-broker-kraken}/pyproject.toml— eachversion, plus the 11 inter-package==pinsuv.lock— regenerated withuv lock; diff is exactly 8 lines (one version per distribution), no unrelated churn;uv lock --checkpasses
9 files, 25 insertions, 25 deletions. Nothing else: a repo-wide grep for 0.12.0 finds only inert prose — the example ISCC.exe /DKeelVersion=0.12.0 invocation comment in packaging/keel.iss (the real value is passed as a define by the workflow; the #ifndef default is 0.0.0), and the two comments narrating the historical v0.12.0 attestation dispatch failure in tests/test_desktop_packaging.py and .github/workflows/release.yml. No live pins or expectations left behind.
Lock discipline
The trap from #422/#482, verified TDD-style: bumping only the root pyproject.toml's version first made tests/test_packaging.py fail exactly as designed —
FAILED tests/test_packaging.py::test_every_workspace_package_carries_the_same_version
FAILED tests/test_packaging.py::test_workspace_siblings_are_pinned_to_the_exact_version[keel-trader]
keel-trader depends on 'keel-core==0.12.0'; it must be 'keel-core==0.12.1'
After completing the set (all 8 pyprojects, 14 pins, uv lock), the same suite is green. uv.lock rides in the same commit because the release workflow checks it is current before anything else (#424).
Gates
uv run pytest tests/test_packaging.py -q— 22 passeduv run pytest -q— 4422 passed, 3 skippeduv run ruff check .— all checks passeduv run mypy— no issues in 350 source files
Do not merge unreviewed: the release workflow runs next, after merge, with this same version.