Built from 24aa56f. Version binds to this hash:
keel --version reports keel 0.10.0+24aa56f2ef58 [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.10.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
docs(research): money-management and fiqh source review (#366)
Fixes #365 (deliverable: the review document itself).
docs/research/2026-08-18-money-management-and-figh-source-review.md — eleven sources (10-post keeks series, Fortune's Formula, the Abu Jib & Hashem 2019 Fiqh Academy paper) read and mapped against keel's architecture.
Executive summary: (1) the headline finding is confirmatory — Kelly run on keel's measured edges says bet zero, which is what keel already does; (2) keel's risk-based fixed fraction is the strongest member of the fixed-fraction family the series covers; (3) adoptions proposed are instrumentation around that posture: R1 Kelly diagnostic in promotion/simulate output (report-only, adoptable now), R2 quarter-Kelly live-path ceiling (spec now, implement only when a rule promotes), R3 drawdown-throttled sizing as an hourly-paper experiment via the trials ledger, R4 CPPI framing of rail 11 in the runbook; (4) explicit rejects recorded with reasons: Optimal-f, streak-driven sizing, a Merton γ knob; (5) the fiqh paper's 'define each instrument type precisely before ruling, through qualified bodies' is keel's attested fails-closed screening argued from the fiqh side — reading-list and AAOIFI/IFSB watch items only, no code.
Docs-only PR; no sizing, rail, gate, or attestation changes.
docs(specs): PRD — keel-broker-alpaca, US equities via the broker port (#373)
Docs-only: the PRD for keel-broker-alpaca, captured from the operator conversation (a contributor has volunteered for the adapter build).
Tracked in milestone Phase 12 — Stocks via Alpaca: #369 adapter + conformance suite (Phase A — the advertised dream first contribution), #370 equities paper profile + attestation semantics + runbook (Phase B), #371 cost-fidelity restatement + DCA benchmark (Phase C), #372 cash-account/no-margin posture. Lineage: Phase 5 broker port (milestone 8), interacting with #233 (capability-based venue visibility).
Hard constraints carried in the PRD: long-only spot; cash accounts only (no margin — riba, and it sidesteps PDT's margin threshold while T+1 settlement must be documented); per-(venue, instrument) attested screening with operator-supplied classifications; no live path in any phase — the promotion gauntlet applies unmodified, and commission-free is treated as not-cost-free (regulatory pass-throughs, spread, data tiers measured before any claim is believed).
docs(specs): refine the alpaca PRD against LEAN's adapter and Alpaca's product surface (#374)
Refines the Phase 12 PRD after exploring the two sources the operator named: QuantConnect/Lean.Brokerages.Alpaca and alpaca.markets.
New requirements: FR-9 session & calendar awareness (equities are not 24/7 — staleness rails must read market-closed on weekends/holidays; Alpaca's 24/5 overnight sessions explicitly off); FR-10 corporate actions (splits/dividends/tickers as first-class events, fail-closed on unreconcilable actions, adjusted-vs-raw candle policy); FR-11 rate limits + paper/live host selection under #233. FR-3 extended with fractional/notional sizing, native bracket/OCO, MOO/MOC. New §5: dividend purification as a recorded operator-policy obligation.
Non-objectives hardened against the venue's monetization surface: no stock lending (conflicts with qabd; interest-like income; opt-out operator-verified), no high-yield sweep (riba), no tokenized equities, no Alpaca crypto, no OAuth/FIX/MCP natural-language trading (the engine is deterministic and advertises it), Broker API recorded as the hypothetical future compliance-SaaS surface — subscription model, never trade commissions.
Docs-only; refines #369–#372 without changing their scope.
docs(fiqh): Lahlou speculation-risk scholarship analyzed and cited as method support (#377)
Fixes #376.
docs/research/2026-08-18-lahlou-speculation-risk-sources.md — analysis of Dr. Mohamed Talal Lahlou's scholarship (doctoral thesis Marchés financiers islamiques et risque de spéculation, 2020; 2019 definition-and-tree of speculative situations; 2014 Cahiers recovery-rule article), mapped to keel: his method (structural definition + classification tree; situations over intentions) is the same genus as the rails; the recovery rule is rail 17's French-language qabd pedigree.
Honesty guarantees baked in: no endorsement, review, or involvement by Dr. Lahlou is claimed — his skeptical position on trading stands unmodified; full texts were bot-gated so every inference is flagged against what was verifiable (titles, defense record, recension, citing theses); the works join fiqh-basis.md as method support, not attestation sources; the fiqh-basis subsection also adds the Abu Jib & Hashem 2019 Fiqh Academy taxonomy paper, completing the earlier source-review recommendation.
Docs-only; no screening, rail, or attestation changes.
docs: trademark and originality posture for venue adapters (#380)
Fixes #379.
- README gains a Trademarks paragraph under Disclaimers: venue names are nominative use; keel is a client of each venue, not a product of it; no affiliation/endorsement/sponsorship; adapters are original implementations against publicly documented APIs.
- Phase 12 PRD gains a Trademark and originality posture section: originality requirement, license+attribution rule for any ported third-party code, requirements-only reference study of Apache-2.0 projects (e.g. LEAN's adapter), no venue branding.
Also records on #379: #369 is now operator-driven (the prospective contributor stays welcome anytime).
Docs-only.
fix(tui): render and scope the activity feed on the UTC clock it runs on (#381)
The bug
The activity overlay stamped rows in local time while the deployment defines its unit of work in UTC.
keel-live-run.sh gates and stamps once per UTC date, firing at the first trigger at or after 01:00 UTC (the instant turtle_breakout._completed_days stops withholding the daily bar that closed at 00:00 UTC). So the day's only cycle lands at ~01:20 UTC. Rendered at UTC-4 that reads 21:20 on the previous date, and two things followed:
- The newest row showed yesterday's date all day — a deployment that had run correctly hours earlier looked a full day stale.
"today", being a local calendar day, excluded its own only cycle. Every day of the year, in every zone behind UTC. The default view of the overlay built to prove the agent is alive was permanently empty.
Reported from the live deployment: the feed showed 2026-08-17 21:20:06 as newest on 2026-08-18, and .keel-live-last-run said 2026-08-18.
The fix
The feed moves to the frame the work is defined in:
| before | after | |
|---|---|---|
_stamp / _clock |
local | UTC, under a when (UTC) heading |
scope_start_ts |
local calendar day | UTC calendar day |
scope_label |
today (2026-08-18) |
today 2026-08-18 UTC |
_day_phrase |
local calendar days | UTC calendar days |
_next_due_lines |
around 09:00 local |
around 09:00 UTC |
tui.py's _human_dt stays local on purpose, and _stamp now documents why: autonomy deadlines and position open times belong to the operator's day; a cycle does not.
Each row also gains an age column (47m, 22h, 3d), measured against ActivityFeed.now_ts so every row on one repaint agrees with the header above it. A UTC stamp cannot be compared to a local wall clock at a glance — that arithmetic is exactly what made a fresh feed look stale — so the row states the answer rather than leaving it to be computed.
scope_label drops the brackets around its date so that spelling UTC out in full keeps the default headline inside 80 columns (the existing test pins that).
Verified against the live log
today now shows the day's cycle; before this it showed nothing.
scope: today 2026-08-18 UTC · 1 cycle · 43 older hidden · press t to widen
when (UTC) age mode sig blk ent exi err what happened
▸ 2026-08-18 01:20:06 22h autonom 0 0 0 0 0 6 products / dca, turtle_breakout quiet
▸ 2026-08-17 01:20:04 46h autonom 0 0 0 0 0 6 products / dca, turtle_breakout quiet
Tests
Full suite: 2986 passed, 1 skipped. ruff check clean, mypy clean.
New coverage, beyond retargeting the existing local-clock assertions:
test_a_cycle_early_on_the_utc_day_stays_in_today_all_day_in_a_zone_behind_utc— the production regression, written as the deployment actually behaves (cycle at 01:20 UTC, read at 23:28 UTC the same UTC date).test_cycle_row_renders_utc_time_not_an_epoch_float_and_not_local_timetest_the_when_column_is_labelled_utc_so_the_frame_is_stated_not_inferredtest_the_age_column_answers_freshness_without_timezone_arithmetictest_age_is_blank_not_invented_when_the_caller_has_no_clocktest_age_of_a_row_stamped_in_the_future_reads_now_not_a_negative_durationtest_header_and_row_columns_line_up_with_an_age_too
…truncated — full description in #381.
feat(broker-alpaca): keel-broker-alpaca — original Alpaca port adapter, conformance green (#382)
Phase A of the keel-broker-alpaca PRD (#369): packages/keel-broker-alpaca, an original implementation of the keel-broker-api port against Alpaca's publicly documented Trading + Market Data REST APIs — raw REST over an injected requests transport (the keel-broker-robinhood convention), no alpaca-py SDK, no third-party adapter code. Zero changes under keel/.
FR-by-FR coverage
- FR-1 Package — workspace-pinned pyproject (
keel-core==0.9.3,keel-broker-api==0.9.3),py.typed, registered underkeel.brokersasalpaca, wired into the dev group (optional venue, same posture as robinhood), mypy strict from birth, Dependabot entry + pip-audit export exclusion. - FR-2 Venue identity —
venue = "alpaca", USD quotes,asset_classes = {"equity"}(the port's keel-side vocabulary, not the venue's casing). - FR-3 Orders — all four port kinds:
market_ioc_quote→ Alpaca's notional market order (notional+type: market+time_in_force: day, as the docs require),market_ioc_base→ fractionalqtymarket,limit_gtc/stop_limit_gtc→gtclimit/stop-limit. Fractional sizes render positionally (format(d, "f")), never scientific notation.extended_hours: falseis pinned on every body. - FR-4 Preview — Alpaca has no preview endpoint, so the preview is synthesized, following the sibling precedent for exactly this case (
keel-broker-robinhood:synthesizes_preview=True/supports_native_preview=False, and its reasoning that no endpoint which prices a quantity is a broker's quote). The book read isGET /v2/stocks/{sym}/quotes/latest;best_bid/best_askride inPreview.detail(feeding the #332 warning and the #350 spread gate), and pricing uses only the crossed side — ask for buys, bid for sells.ap/bp= 0 is the venue's documented "no active side" and lands inPreview.errors, never as a price. - FR-5 Market data —
15Min/1Hour/1Day→FIFTEEN_MINUTE/ONE_HOUR/ONE_DAY; every other granularity refused withValueError(the port's sanctioned refusal). Pagination followsnext_page_token, bounded like the sibling transports. Data tier is a declared capability: constructor-validatediex|sip, sent on every data request — never the venue's silentsipdefault. - FR-6 Balances/positions — USD row with
available = min(buying_power, cash): on a cash account (multiplier == 1) the gap between the two is exactly the unsettled T+1 proceeds, surfaced honestly. Long positions mapqty_available→ available,qty→ total; short rows are skipped (long-only by construction). - FR-7 Fees — commission $0; sells modelled with the regulatory pass-throughs in
fees.pyas provenance-commented constants: SEC Section 31 $22.90 per $1M of proceeds (Alpaca's own regulatory-fees page, live-read 2026-08-17; the SEC's advisory 2026-2 moves it to $20.60/$1M as of 2026-04-04 — recorded as a documented re-measurement point per PRD §8's regulatory-drift rule), FINRA TAF $0.000166/share capped at $8.30 (the cap is on Alpaca's page; the rate is FINRA Schedule A §4(b)(7), in force since 2021-01-01). The model feedsPreview.est_feeon sells; buys are honestly zero. CAT is a documented sub-cent omission.
…truncated — full description in #382.
fix(broker-alpaca): NaN-safe money coercion, host-map-only construction, quoted path segments (#384)
Follow-up to #382 (merged pre-review): the adversarial review's five findings, applied TDD red-first.
- [major] NaN/Infinity coercion hole —
_decimal_or_noneparsed'nan'/'Infinity'successfully (JSONNaNarrives viaparse_constant), crashingpreview_orderatbid > 0andquote_size / crossed,get_balancesatmin(buying_power, cash), and silently poisoning Candle OHLCV — contradicting the preview docstring's populate-errorsinvariant. Nowis_finite()-guarded; three red-first tests cover quote sides, balances, and bar leaves. - [minor] Host-map-only construction — the dead
trading_host=/data_host=overrides (zero callers anywhere) are REMOVED;TRADING_HOSTSis the only endpoint→host map and the README's "no configuration path from a paper credential to the live host, by construction" is now literally true (pinned by test). - [minor] Quoted URL path segments — symbol/order-id interpolations percent-encoded (
safe="") at all four sites;A/B-USDand a crafted order id can no longer reshape the request (asserted on the outgoing URL). - [nit] Naive timestamps refused — offset-less RFC3339 no longer silently read as local time;
ValueErrorper the module's refusal convention. - [nit] SEC fee framing — the provenance note now states the advisory rate (.60/$M) is already in force and Alpaca's page is the stale side; the encoded venue-published .90/$M stays deliberate (conservative direction, ~$0.02/$10k) with the delta as the re-measurement trigger.
Gates: uv run pytest -q → 3110 passed, 1 skipped; ruff clean; mypy clean (249 files).
Refs #369 (Phase A hardening).
feat(port): session-aware venues — market clock in the port, closed-session semantics in the engine (#370 B1) (#385)
What
Phase B1 of #370 (FR-9, PRD docs/superpowers/specs/2026-08-18-keel-broker-alpaca-prd.md): the broker port gains a first-class session concept, and the engine consumes it — a closed equities market reads market closed, never feed stale.
Equities are not 24/7. keel's feed-staleness rails and the agent's cycle semantics assumed a 24/7 venue: on a weekend or market holiday, an equities feed that is simply closed would be reported STALE (the crypto semantics false-positiving), and cycles would log staleness-gated noise.
The port surface
BrokerCapabilities.session_bound: bool— REQUIRED, not defaulted (packages/keel-broker-api/keel_broker_api/capabilities.py). fake/coinbase/robinhood declareFalse(24/7); alpaca declaresTrue. A default would be an answer to a question only the venue knows, and the likeliest default (24/7) is exactly the one that reads a closed equities venue as a stale feed.Broker.market_clock() -> SessionState(port.py;SessionStateinresults.py:open|closed|clock_unavailable). Alpaca reuses Phase A's/v2/clockread —is_market_open()now delegates tomarket_clock()so the two can never disagree. The 24/7 adapters answerOPENas a constant with no network call (pinned by the no-transport adapter tests).- The conformance suite holds all four adapters to the new surface (
conformance/suite.py).
FR-9 mapping (engine)
- Cycle skip with kill-switch parity (
keel/agent.py::run_once): the session gate sits directly after the kill-switch and skips exactly like it — no poll, no evaluation, no orders — loggingagent.cycle_skippedwithreason: market_closed(INFO; a weekend is expected) or the distinctreason: market_clock_unavailable(WARNING; "could not know" and "we know it is shut" are different operator facts). The kill-switch still outranks. - Fail-closed clock rule: a session-bound venue's clock that cannot be read answers
clock_unavailable— never an exception, never a guess of open. The engine belt-and-braces the same way (a third-party adapter that raises instead of answering still fails closed); the cycle never crashes on a clock read. - Recording for broker-free surfaces: each cycle writes the venue's answer to
agent_state(market_session/market_session_ts). fetch --check(still offline) reads that recording viaagent.recorded_market_closed: stale-but-present series render as the distinctCLOSEDstate and do NOT count as missing/stale for the exit code — a weekend must not page an operator. Deliberate carve-outs:missingstill alerts (a closed venue serves history);clock_unavailabledoes not defuse alerts (fail-closed for trading, fail-loud for alerting); the recording is trusted only for rail 12's feed-heartbeat window (interval_sec * FEED_STALENESS_CYCLES) so a dead agent cannot silence alerts forever.keel status/keel tui: a distinct session line directly under the kill-switch line (the #345 rail-17 line precedent; no paper carve-out — the session gate skips paper cycles too, so the same line is truthful in every mode).
Crypto unchanged — guaranteed at every seam
…truncated — full description in #385.
feat(paper-equities): the equities paper profile — tracked config, venue selection, deployment artifacts, runbook (#370 B2) (#386)
What
Phase B2 of #370 (PRD docs/superpowers/specs/2026-08-18-keel-broker-alpaca-prd.md, Phase B / §5, FR-9/FR-10 posture): the equities paper profile — keel's daily turtle rules on US equities via Alpaca's PAPER API, on their own database, accruing hash-chained paper evidence end-to-end — plus the minimal engine wiring it needs: config-driven venue selection.
The profile (tracked, mirrors the paper-hourly pattern)
config.paper-equities.yaml—broker: {name: alpaca, endpoint: paper, data_feed: iex};market_data.granularitiesis ONE_DAY only (a daily clock — hourly bars exist only within sessions and daily rules do not read them; stated in comments);interval_sec: 86400; weights sum to exactly 1.000;risk_pct: 0.01; caps mirroring the paper profiles; the paper-hourly-style honesty caveat in the header.- Deployment artifacts —
com.keel.paper-equities.plist(strict-XML validated; schedule below),paper-equities-run.sh(UTC-day stamp,set -e, stamp only after a successful cycle, session-window guard),keel-equitieswrapper (config +keel-equities.dbtravel as a pair). - Runbook —
docs/operator-runbook.mdgains "The equities paper profile": why it exists (evidence on a session-bound venue; the same honest no-edge caveat), bootstrap (Alpaca paper account, env vars,keel migrate --db keel-equities.db,rules add --kind turtle_breakout --product SYMBOL --params '{"granularity": "ONE_DAY"}'+rules promote --forcewith the documented bypass rationale,fetchwarm — B1 makes weekends quiet), the fourth profile-comparison column, attestation semantics (operator-supplied per(alpaca, SYMBOL), AAOIFI/IFSB-class sources per the fiqh source review; the engine never classifies), rail 17 as ACATS transfer-out, the T+1 × daily-cadence interaction (immaterial for entries; exits never spend), and the two operator-verified opt-outs (stock lending OFF — qabd; high-yield sweep OFF — riba; where to verify each in the Alpaca dashboard)..env.examplewires the new key names.
Venue selection (the engine half)
A new broker: config section — name (→ keel.brokers entry-point discovery), endpoint (paper|live), data_feed (iex|sip), both validated at load — threaded through the one broker-construction seam every CLI/agent/TUI path shares (keel/commands/_common.py::_build_broker), plus telemetry's bind_venue in _load_cfg. Secrets follow the CDP pattern: load_alpaca_secrets reads ALPACA_API_KEY_ID/ALPACA_API_SECRET_KEY from the environment or .env; the missing-keys error names the venue and both variables.
When the section is absent, the Coinbase construction path is byte-compatible — pinned by a test that drives the real _build_broker against monkeypatched RESTClient/CoinbaseClient and asserts the exact kwargs — and every pre-existing test stays green unchanged. The config golden baseline is regenerated deliberately (its own documented step) for the new field.
The candidate set — PAPER CANDIDATES
…truncated — full description in #386.
docs(specs): PRD — the TUI as keel's operator console (#393)
The operator-console PRD, with the full current feature inventory (§2) and the menu-tree v1 (§3).
Milestone Phase 13 — TUI: the operator console: C1 #387 service-extraction audit (foundation — TUI dispatches to exactly what the CLI calls, no duplicated logic), C2 #388 console shell + guarded profile switching across the four deployments, C3 #389 compliance menu + the scout-results admission handler (propose → screen → human-typed attest, via the existing admission services), C4 #390 rules + research readers, C5 #391 trading + data menus, C6 #392 safety & polish (adversarial pass on the typed-confirmation contract and live guards).
Hard fences (PRD §4/O3): no new business logic — fix the service and both front-ends get it; no auto-trading born in the TUI; typed actions keep their typed prompts; the scout handler never auto-attests.
Docs-only.
docs(specs): console PRD — venues/brokers visibility and the help & glossary system (#395)
Operator additions to the console PRD: O7 venues/brokers visibility (a brokers service + keel brokers list + the TUI Venues browser; capabilities not key-presence; no secrets) and O8 newbie-friendly help & glossary (per-screen/per-action plain-English help, single-source definitions, typed-prompt disclosure). Adds C7 (#394) to the phasing, the Venues submenu to the menu tree, and success criteria 6-7. Docs-only. Refs #394.
docs(specs): console PRD — the session banner (O9) (#396)
Operator addition to the console PRD: O9 — every screen's header shows the active profile + market session state + the venue market clock (next open/close for session-bound venues via a small port extension carrying next_open/next_close; '24/7' for always-open; CLOCK UNAVAILABLE fail-loud when stale/absent, matching fetch --check). Applies to live and paper profiles alike; sourced only from the recorded session state and the market_clock() service — no TUI-side calendars, no new session logic. Woven into the menu tree (per-screen header line), C2's scope (#388), and success criterion 8. Docs-only. Refs #388.
docs(specs): console PRD — parameter-level help, single-sourced from the rule classes (#397)
Operator addition to O8: parameter-level help. Every rule-kind parameter (your example: turtle_breakout's entry_lookback, exit_lookback, adx_period, adx_threshold, atr_period, atr_stop_mult, use_macd_confirm, s1_filter, min_volume_filter, volume_ma_period, volume_mult, target_rr, granularity — and every other kind's equivalents) gets an in-console plain-English explanation: meaning, what changing it does and which gate it feeds, type, default, shipped range.
Single-source rule: the truth lives in the rule classes (RULE_REGISTRY constructors); missing per-parameter docstrings get added AT THE CLASS, and the help renders them by introspection — no hand-maintained duplicate table that drifts from the code. Contextually invokable wherever params render: rules list detail, add forms, backtest views. Scope note added to #394 (C7). Docs-only.
docs(specs): console PRD — the 'Shariah in force' browser (O10) (#398)
Operator addition to the console PRD: O10 — 'Shariah in force' for the current session (scope noted on #389/C3).
A Compliance submenu showing what the engine enforces for the ACTIVE profile, strictly read-only from existing sources: per-instrument attestations with attributed sources/rulings/dates (via the assets list service) and exemptions in effect; the fiqh-derived rails each with a plain-English explanation and a citation into docs/fiqh-basis.md (never a TUI-written summary); and the standing honesty lines — not a fatwa engine, no scholarly review has occurred — always visible. The help menu (O8) explains the fiqh vocabulary (qabd, riba, gharar, maysir, attestation, exemption, purification) anchored to fiqh-basis.md so definitions cannot drift from the document that states them. Success criterion 9 added. Docs-only. Refs #389.
refactor(cli): extract command bodies into the shared service layer (#387 C1) (#399)
C1 of the TUI-operator-console PRD (§5): O2 — one implementation, two front-ends. Every CLI command body in keel/cli.py that carried logic now delegates to a service in keel/commands/*; the wrapper parses click options, builds the broker lazily at the _build_broker seam, calls the service, and prints/raises. keel/cli.py shrinks by ~1280 net lines and gains no behavior.
Audit → destination
| command | class | shared implementation |
|---|---|---|
fetch |
(b) extracted | new keel/commands/fetch.py — assess_products (window-bounded sweep), render_freshness, run_fetch (check/skip/warm/repair flow, FetchResult) |
monitor |
(b) extracted | new keel/commands/monitor.py — monitor_cycle, run_monitor (FR-9 session-aware loop, once-per-state-change skip lines) |
simulate |
(b) extracted | new keel/commands/simulate.py — run_simulation + the whole assembly (candles, #259 slippage pass, account metrics, tier matrix, verdict, trials row, report/artifact write) |
assets screen / holdings / discover |
(b) extracted | new keel/commands/assets.py — screen_product (THE admission gate), market_facts, VENUE, gather_holdings+render_holdings, run_discovery+render_discover, screen_products, broker_auth_hint |
agent's confirm gate |
(b) extracted | new keel/commands/confirm.py — _interactive_confirm, preview reading/rendering, all markers |
kill / resume / resume-entries / record-flow / reset-hwm |
(b) extracted | new keel/commands/trading.py — the state mutations + render_loop_result (typed prompts stay front-side per O3) |
pnl |
(b) extracted | new keel/commands/pnl.py — build_pnl_report + render_pnl_report |
purification |
(b) renderer extracted | new keel/commands/purification.py — render only; compute was already keel.compliance.purification |
db trials withdrawals autonomy rules subscription status insights versions tui |
(a) already shared | keel/commands/* modules; keel.agent; keel.data.* |
assets attest/attest-instrument/exempt/unexempt/list/propose |
(a) already shared | Repository writes / keel.proposer |
agent cycle/loop |
(a) already shared | keel.agent.run_once/loop |
init / init-config / migrate |
(a) — deliberately NOT extracted | template read + keel.data.db.migrate; scaffolding ops absent from the TUI menu tree |
Also: keel/commands/admission.py now imports the discovery defaults from keel/commands/assets.py instead of mirroring the CLI options by hand (one home for DEFAULT_MIN_QUOTE_24H_VOLUME / DEFAULT_DISCOVER_LIMIT), and keel/commands/tui.py's two lazy from keel.cli import _screen_product imports now come from the service layer — no cycle dodge left.
Byte-compatibility guarantee
…truncated — full description in #399.
docs(specs): console PRD — the strategy console (O11) (#400)
Operator additions to the console PRD, owned by C4 (#390): O11 — the strategy console: (1) simulations run from the console with results displayed (GO-LIVE/TRAIN-MORE verdict, gates, DCA benchmark, tier matrix; report linked from the Research readers); (2) the tried-vs-used ledger — every strategy with its lifecycle status AND the machine's recorded reason it isn't in use (failing promotion floor, insights distance, disabled/demoted context); (3) add-a-strategy in-console with parameter help at each field; (4) retry — re-backtest + re-promote (--force typed) and rules enable as the restore path. All through the C1 services. Docs-only. Refs #390.
feat(tui): the console shell — menu navigation, guarded profiles, the session banner (#388 C2) (#401)
What
C2 of the TUI-operator-console PRD (issue #388): the console shell around the existing dashboard, profile switching across the four deployments, and the session banner on every screen. The dashboard stays the landing screen; nothing working was rewritten.
The shell's structure
keel/commands/console.py(new) holds the pure model: the menu tree, the profile convention, the switch/guard logic, and the banner builders — directly unit-testable without curses, mirroringtui.py's pure-model/thin-loop split.run_livegains three modes —menu(the PRD §3 tree, cursor + 1-9 ordinals),profile(the deployment menu),placeholder(a future slice's notice) — andmin normal mode opens the menu. Every screen, all ten modes, is prepended the two-line session banner (fail-soft read).run_livetakes the shell as an optionalconsole_binding: a caller that passes none gets the pre-C2 dashboard byte-for-byte — which is what keeps every pre-existing test unchanged.--onceheads its frame with the same banner.- Only Dashboard / Profile / Help act in this slice; Trading, Rules, Compliance, Data, Research, Account render a "lands in C3/C4/C5 — navigation only" notice (owners per the PRD §5 phasing; Account rides with C5, commented as such).
The profile convention (O4)
The four deployments are discovered from the tracked config files present in the working directory — the same place the CLI resolves its relative --config/--db paths. The pairs are what the wrappers (keel-paper, keel-live, keel-paperhourly, keel-equities) and the runbook's deployment table pin:
| profile | config | db | guarded |
|---|---|---|---|
| paper-forward | config.paperforward.yaml |
keel.db |
no |
| live | config.live-sandbox.yaml |
keel-live.db |
yes |
| paper-hourly | config.paper-hourly.yaml |
keel-paperhourly.db |
no |
| paper-equities | config.paper-equities.yaml |
keel-equities.db |
no |
The convention is stated as a comment in console.py; no new registry file. A switch rebinds config and db together through the same _load_cfg/_open_repo loaders every CLI command uses (ConsoleBinding.open_state), so one action rebinds every screen; a pair not in the registry — a config on another deployment's db — is refused outright. Selecting LIVE asks an explicit y/N at the terminal (a confirm step, deliberately not O3's typed contract: it changes what the operator is looking at, not what the engine does) and, once active, the header marks it unmistakably (LIVE (REAL MONEY), alert style). No secrets anywhere in the header or profile display — file names and venue declarations only.
The port schedule extension
market_clock() is untouched. Broker.market_schedule() is added, returning a frozen MarketSchedule(state, next_open_ts, next_close_ts):
…truncated — full description in #401.
feat(tui): the compliance menu — admission flows, the scout handler, Shariah in force (#389 C3) (#403)
C3 of the TUI-operator-console PRD (issue #389): the Compliance sub-menu (PRD §3), the scout-results admission handler (O6) and the "Shariah in force" browser (O10) — all dispatching to the existing C1 services; the TUI stays thin (O2). Test-first: the new tests were red before the implementation landed.
Entry → service dispatch (every entry, an existing service or repository function)
| Compliance entry | Dispatches to |
|---|---|
| screen | admission.build_screen_report over assets.screen_product (the one admission gate) |
| propose | admission.build_propose_view over proposer.parse_proposal/build_proposal_report |
| attest (typed) | Repository.upsert_asset_attestation — the exact call/args keel assets attest makes, same echo line |
| attest-instrument | Repository.upsert_instrument_attestation (product uppercased, wrapper vocabulary = KNOWN_WRAPPERS) |
| exempt / unexempt | Repository.upsert_screen_exception / delete_screen_exception (criterion restricted to WAIVABLE_CRITERIA, blank rationale refused) |
| holdings | assets.gather_holdings + render_holdings behind ONE Enter-gated get_accounts (opens ARMED, holds the result — the discover overlay's own gating story) |
| discover | admission.build_discover_report over one Enter-gated list_products |
| Scout results… | the browser below (O6) |
| Shariah in force… | the browser below (O10) |
| subscription show / attest / set | the bodies extracted into keel/commands/subscription.py (subscription_show_lines / apply_subscription_attest / apply_subscription_set) — one implementation, two front-ends; the CLI commands delegate, byte-compatible (their tests unchanged and green) |
| withdrawals attest (typed) | the CLI's own two set_state writes + echo lines |
| purification | compliance.purification.build_report + render_purification_report (report-only) |
The typed contract (O3), sacred
withdrawals attest --enabledkeeps the CLI's own gate —clis_typed_withdrawals_gatecalls_require_interactive_confirmation("attest withdrawals as ENABLED", …)verbatim, rendered in-console via the curses suspend/restore dance, failing closed;--suspendedstays ungated (the CLI's asymmetry). A declined gate means not a single state row is written (pinned).attest— marked "(typed)" by the PRD §3 tree — ends its form with a typed gate demanding the operator type the asset code back (the scout flow's attest step is the same form). Never pre-filled, never piped; a wrong phrase writes nothing (pinned for both the menu form and the scout flow, including end-to-end through the live loop).- Forms collect their fields at the terminal inside the console session (
_run_terminal_form: suspend → prompts → restore) and their confirmation line toasts on the menu — every write shows what it did.
The scout-results handler (O6)
…truncated — full description in #403.
feat(tui): the strategy console and the research readers (#390 C4) (#404)
The console's fourth slice: the Rules menu becomes the full try/evaluate/retry loop of PRD O11, and the Research menu lands the O5 evidence readers. Everything dispatches to the C1 service layer; no sizing, gating or reporting math is born in the TUI.
The tried-vs-used ledger's sourcing (O11.2)
Every rules-table row rendered with its lifecycle status AND the machine's recorded reason it sits there:
can_promote's ownreasons— the exact linesrules promoteprints on a refusal: which floor failed (n_trades N < min_trades 100, expectancy/rr/win_rate), and the G4/PBO axis honestly NOT RUN — computed from a fresh backtest over the repo's cached candles at the config's own fee (_backtest_fee+_describe_feeprovenance).- the
insightsservice's promotion-gate distance forpaperrules (build_rule_track_record/track_record, the trades_remaining wording). - the rows' own
promoted_at/demoted_atstamps for live/disabled rows. - A rule with no cached candles renders "no backtest on record" — the honest absent case, never a TUI-authored narrative.
The ledger computes once per entry (every row's backtest is real work) and is held while open. Its detail view renders every param through describe_params.
The simulate UX decision (O11.1)
An ARMED view showing the target report path first — Enter IS the confirm step — then run_simulation on the active profile's config/db with the CLI's own defaults (5y, $500/month, allowlist products, history fetched when the cache does not cover the window, mirroring the CLI). The run blocks the loop exactly like f fetch does — the CLI's own UX, mirrored honestly. The progress lines the CLI streams are collected and shown at the head of the results; the verdict/report render verbatim under a pinned verdict+path footer (pinned_frame); closing re-arms. The report lands in the directory the Research/promotion-reports reader lists, newest-first.
The extraction done first (C1 style, byte-compatible)
The validation and write logic of rules add/backtest/promote/demote/disable/enable moved out of the click bodies into keel.commands.rules services (add_rule_row, run_rule_backtest, attempt_promotion, apply_rule_*) that echo through injected stdout/stderr sinks. The CLI wrappers dispatch and are byte-identical — verified against origin/main across 17 command scenarios (diffed outputs, exit codes), and by the untouched test_cli.py/test_rules_add.py suites. The G4 loader stays injectable (load_pbo) so the _load_pbo test seam keeps working unchanged. --force on the gated path loads config only where the old body did.
O3 held: promote asks an explicit y/N in-console; --force runs only behind the CLI's OWN _require_interactive_confirmation typed gate (clis_typed_promote_force_gate, exact phrase, never pre-filled, failing closed); no auto-promotion anywhere.
describe_params design
…truncated — full description in #404.
feat(tui): the trading and data menus (#391 C5) (#405)
The Trading and Data menus — C5 of the TUI-operator-console PRD
Per the PRD §5 C5 + O3: every entry dispatches to the C1 service layer (one implementation, two front-ends), and every typed action keeps the CLI's OWN typed prompt rendered in-console — identical prompts, logs and audit events to the CLI; never piped, never pre-filled, never bypassed. No TUI-originated order path bypasses the agent pipeline (pinned: the cycle's confirm_fn IS keel.commands.confirm._interactive_confirm, the same object keel agent hands the executor).
Entry → service dispatch
Trading menu (keel/commands/trading_console.py):
| entry | dispatch |
|---|---|
| agent cycle (single) | ARMED confirm → agent.run_once via run_agent_cycle (confirm_fn = the CLI's own _interactive_confirm); result held: render_loop_result + render_blocked_entries, a skip's logged reason verbatim |
| monitor poll (single) | ARMED confirm → monitor.monitor_cycle, the CLI's own products/granularities/interval derivation |
| autonomy | repo.set_autonomous with the CLI's own asymmetry: ON behind autonomy.autonomy_on_gate (extracted, byte-identical wording), OFF ungated; result lines are the CLI's own |
| record-flow [typed] | the CLI's own order (gate naming the RAW amount first, then validation), parse_flow_amount, trading.record_flow, render_flow_recorded |
| reset-hwm [typed] | clis_typed_reset_hwm_gate → reset_high_water_mark |
| resume-entries [typed] | clis_typed_resume_entries_gate → clear_consecutive_loss_halt |
| kill | engage_kill_switch, immediately — the CLI's own contract is one key with no ceremony; none added, none removed |
| resume [typed] | clis_typed_resume_gate → disengage_kill_switch |
Data menu (keel/commands/data_console.py):
| entry | dispatch |
|---|---|
| fetch | ARMED plan (products × granularities × 5y window from the ACTIVE profile) → fetch.run_fetch itself, lazy broker factory, blocking like the CLI, streamed lines held and rendered verbatim |
| fetch --check | its own ARMED entry; the exit verdict (the service's own error) renders, pinned under the scroll |
| repair gaps | ARMED confirm → run_fetch(repair_gaps=True), per-series outcomes in the held stream |
| freshness overview | offline, per poll: run_fetch(check=True)'s own sweep — never constructs a broker (pinned) |
| db import | path form → db.validated_import_dir (the CLI's own click.Path check) + csv_import.import_dir + render_import_result; a bad path refuses with the CLI's exact line, pinned byte-for-byte against a real CliRunner run |
Extractions (C1 style, CLI byte-compatible, its tests unchanged)
keel/commands/trading.py— the RESUME / RESUME-ENTRIES / RESET-HWM / RECORD-FLOW gate wording (single home, thewithdrawals.pypattern),record_flow_action,parse_flow_amount, the four confirmation lines (KILL_ENGAGED_LINE, …),render_flow_recorded,render_blocked_entrieskeel/commands/autonomy.py—autonomy_on_gate,autonomy_expiry,render_autonomy_on,AUTONOMY_OFF_LINEkeel/commands/db.py—validated_import_dir,render_import_result
Gate parity proofs
…truncated — full description in #405.
feat(tui): venues visibility and the help & glossary system (#394 C7) (#406)
Closes the console PRD's C7 slice: O7 (venues/brokers visibility, service-first) and O8 (the newbie help & glossary system). Test-first: the new suites were written red (tests/commands/test_brokers.py, tests/commands/test_help_console.py — both failed at import before a line of implementation), then made green.
The brokers service (O7) — one service, two front-ends
keel/commands/brokers.py::list_installed_brokers() walks the entry-point registry (discover_brokers()), constructs each adapter credential-less, and renders one frozen BrokerInfo row per installed adapter:
name, venue, deployment ("wired-for-deployment" | "optional-dev-venue"),
session_bound, quote_currencies, asset_classes, supported_orders,
preview ("native" | "synthesized" | "none"), supports_fee_summary,
declared_endpoints, supported_data_feeds, package_version
- Every field derives from the adapter's own declarations (
capabilities(), the newDECLARED_ENDPOINTS/DECLARED_DATA_FEEDSonAlpacaAdapter— derived fromTRADING_HOSTS/SUPPORTED_DATA_FEEDS, never a second list). Pinned: the payload fields equal the adapters' declarations, per field. - No secrets, no key-presence inference (#233-aligned): the field set is closed and pinned by test; the payload is JSON-scanned for secret vocabulary; the screen and CLI both carry the shared
NO_KEY_INFERENCE_LINE. - Wired vs optional is ONE explicit constant (
WIRED_FOR_DEPLOYMENT = {coinbase, alpaca}) with its reasoning in place — no registry signal carries it. - Front-ends:
keel brokers list(+--jsonfollowingstatus --json's convention, no disclaimer after JSON; the README's trademark line in the docstring) and the TUI Venues browser under Profile (console.build_venues_lines): same payload via the service's sharedcapability_factswording, the SELECTED adapter highlighted[selected], the active deployment's binding (config+db pair; endpoint/data feed where the venue declares them) named. The one-payload/two-front-ends equality is pinned by test; the browser's facts are read once per entry, never per poll.
The glossary (O8) — one source, anchored
docs/glossary.md is the ONE new hand-written file and the only place console terms are defined. Seeded with the console's needed terms (rail; attestation + instrument attestation; exemption; screening; promotion gate; paper/live mode; kill switch; autonomy; qabd; riba; gharar; maysir/maisir alias; purification; session-bound venue; market clock; trust window; DCA benchmark; granularity; trials ledger). Design as built:
…truncated — full description in #406.
fix(tui): true glossary definitions, resilient broker listing, complete contextual help (#407)
Follow-up to #406 (merged pre-review-fix): the adversarial review's three blockers + two majors + five minors/nits, applied red-first.
Blockers: ? was dead in the discover overlay (and the every-mode structural test's regex block-extraction was dishonest — absorbed trailing code into the last block, now AST-based); the glossary's rail entry said nineteen rails (it is eighteen — guards.py and fiqh-basis both say so); the promotion gate entry INVENTED a DCA-benchmark floor the gate does not have — rewritten to the real axes (min_trades/min_expectancy/min_rr/min_win_rate + the PBO∧degradation-slope conjunction), pooling's true scope (per parameter SET, sample-size axis only; the overfitting gate is not pooled), and the DCA comparison's actual home (simulate's report, not the gate).
Majors: a raising adapter killed the whole brokers listing (and could crash the TUI) — the service is now total with honest per-adapter error rows on both front-ends.
Minors/nits: stale keys-&-safety text (incl. the pre-C3 placeholder paragraph) updated to the live console; m closes the context overlay; ? works in help mode; WIRED_FOR_DEPLOYMENT now drift-guarded against the tracked configs' actual selections; dead helper wired; garbled glossary sentences repaired; record-flow/reset-hwm typed disclosures; two more verbatim fiqh pins.
Gates: 3605 passed, 3 skipped; ruff/mypy clean. Refs #394.
feat(tui): the console completed — account menu, ceremony audit, thinness pin, runbook (#392 C6) (#408)
C6, the final slice of the TUI-operator-console phase (PRD §5 C6, §6.2, O2/O3/O4). Four deliverables:
1. The Account menu — the last placeholder (keel/commands/account_console.py)
- pnl — the EXACT
keel pnlreport through the C1 services (commands.pnl.build_pnl_report+render_pnl_report) over the active deployment's imported transactions; when nothing is imported it renders its honest empty state namingkeel db import, never a confidenttotal realized P&L: 0. - versions — the same lines
keel versionsprints, through ONE shared renderer extracted tokeel/commands/versions.render_versions_lines(CLI output byte-identical, existing tests unchanged); disagreement renders loud where the CLI exits non-zero; the environment scan runs ONCE per entry, rows held (the venues browser's contract). - The whole branch is READ-ONLY — no write path at all — and keeps the console contracts: banner-aware, m-close, cursor reset on entry,
?contextual help (three new modes registered inhelp_console).
2. The architectural thinness pin (tests/commands/test_console_thinness.py, PRD §6.2)
An AST scan over console.py + tui.py + every *console*.py with four rules:
- No compute-tree imports — nothing from
keel.strategy.*,keel.execution.{guards,sizing,executor},keel.compliance.{screen,purification},keel.analysis.*except constants/types (verified against the source module's own AST — the gate-wording/screen-vocabulary imports) and the audited function imports. - No compute-tree calls — any call resolving into those trees must match a
(module, enclosing function, callee)allowance, each justified inline (the ledger's insights-mirrored paper-gate read; the Enter-gated verdict dispatching to the engine's own backtest+gate; the retry'snext_statuswording; the shared purification renderer; the executor's two READ helpers). Allowances are site-scoped and asserted to name real callees, so they cannot leak. - Decimal is display-only — no arithmetic operator over a
Decimal(...)construction. - No broker construction outside the seams — only inside the
build_broker/build_clientlambda handed to a service (therun_fnseam) or the audited bounded read sites.
Proven false during development on deliberate scratch violations of every rule (sizing/guards/screen calls, Decimal math, a stray _build_broker) — each failed the pin by name — then removed.
3. The typed-confirmation + live-guard adversarial audit, as tests (tests/commands/test_console_ceremony.py)
…truncated — full description in #408.
fix(brokers): correct rh_best_bid_ask to what the venue actually sends (#414)
Closes the fixture half of #413.
The wrong claim
tests/fixtures/rh_best_bid_ask.json carried:
{"next": null, "previous": null,
"results": [{"symbol": "BTC-USD", "timestamp": "...", "bid": "65380.00", "ask": "65480.00"}]}A tidy 15 bps spread BTC-USD does not produce, and next/previous this endpoint does not send.
What the venue actually returns
Raw body, verbatim:
{"results":[{"symbol":"BTC-USD","timestamp":"2026-08-19T19:20:41.579217945Z","bid":"68329.2","ask":"68324.17"}]}Three samples ~2s apart, five pairs, 2026-08-19:
| symbol | ask − bid ① | ② | ③ |
|---|---|---|---|
| BTC-USD | −0.86 bps | −0.28 bps | −1.38 bps |
| ETH-USD | +0.10 bps | −0.29 bps | −1.29 bps |
| DOGE-USD | −0.11 bps | −0.11 bps | −0.59 bps |
| XLM-USD | +3.21 bps | +3.63 bps | +5.09 bps |
| ADA-USD | +2.41 bps | +2.41 bps | +2.49 bps |
Not a label swap — that would invert every pair equally. The legs are sampled independently and then stamped with one timestamp the row does not earn: where the true spread is thinner than the sampling jitter (BTC, ETH, DOGE, all well under 1 bp) they land out of order; where it is wider (XLM, ADA) the ordering survives.
Why the probe never caught it
scripts/robinhood_smoke.py compares shapes, never values. It matched this fixture on every run. This is #217 F4's failure inverted — that fixture invented keys the venue never sends, this one invented an ordering it does not produce, which is harder to spot because a tidy spread is what a reader expects to see.
The fix
rh_best_bid_ask.json— an observed crossed BTC-USD row, nonext/previous.rh_best_bid_ask_uncrossed.json— new, an observed XLM-USD row.
Both, because either alone would be a fresh false claim: a lone crossed row asserts "always crossed" as confidently as the old one asserted "never". That is #230's lesson — a fixture can be wrong by being unrepresentative, not only by being invented.
Two tests asserted bid < ask, one per side of the port, and are inverted. The adapter-side test now pins that the endpoint admits both orderings, and that the crossing is jitter-sized (< 5 bps) rather than a semantic inversion — so a future row crossing by percent reads as the different, worse finding it would be.
transport.get_best_bid_ask gains the warning, including that translate.to_price_side's BUY→ask / SELL→bid mapping inverts on a crossed row (both directions optimistic — the one outcome that mapping exists to prevent). Its field list also claimed rows carry symbol, bid and ask while omitting timestamp, which #217 F8 had already established; corrected.
The README's "the five READ fixtures match observed responses" gains the #413 caveat next to the #230 one.
Scope
No behaviour change — nothing calls this endpoint today. Deciding the contract (normalise vs. declare it unusable for pricing) stays on #413, so a first consumer cannot pick silently.
Full suite: 3556 passed, 3 skipped. ruff check and mypy clean.
fix(scripts): make the robinhood credential guard catch the error it claimed to (#416)
The guard didn't do what its comment said
scripts/robinhood_smoke.py:
A raw Ed25519 seed is 32 bytes, which is 44 base64 characters with padding. Checking this before the first request turns the most likely operator error — pasting the public key, or a PEM, or a hex string — into a precise message instead of a 401.
It cannot. A raw Ed25519 public key is also 32 bytes, so it is also 44 base64 characters, from the same alphabet. Length, alphabet and decoded size are all identical. The check never covered the case it named.
On 2026-08-19 an operator pasted the public key into ROBINHOOD_API_KEY. Every guard passed, every request signed correctly, and the venue returned 401 — indistinguishable from a revoked key, a stale clock, or a signing bug. Telling those apart cost an afternoon.
What actually separates the two values
One is derived from the other. So the guard derives it:
ROBINHOOD_API_KEY holds the base64 PUBLIC key of ROBINHOOD_PRIVATE_KEY, not an API key.
The public key is what you paste INTO Robinhood's credential page. What belongs here is the
identifier Robinhood issues back once the credential exists (rh-api-<uuid>).
Every request would sign correctly and be rejected 401, because the venue has no record of
this key.
Fix: sign in to web classic, open https://robinhood.com/account/crypto, choose Add key, paste
the value currently in ROBINHOOD_API_KEY as the public key, tick the API actions this
credential needs, and put the identifier it returns here. ROBINHOOD_PRIVATE_KEY stays as it
is -- the keypair is already correct.
That is the verbatim output of the new guard run against the real .env that produced the incident.
Two checks, most-specific first:
ROBINHOOD_API_KEYequals the public key ofROBINHOOD_PRIVATE_KEY— named outright, with the fix, because this one needs no round trip to diagnose: the value in the wrong variable is exactly what the credential page wants.ROBINHOOD_API_KEYis any 32-byte base64 value — an Ed25519 key is never an API key identifier whatever the identifier format turns out to be, so this gates even with nothing to match against.
Two details that are load-bearing
validate=True on the decode. Without it b64decode discards out-of-alphabet characters rather than refusing, so a genuine rh-api-<uuid> — which contains - — could decode to 32 bytes by accident and be rejected as a pasted key. Pinned by test_a_real_api_key_is_not_mistaken_for_base64.
The rh-api-<uuid> shape is quoted, not enforced. It is an observation about one credential, not a documented contract; a hard gate would reject a valid key the day Robinhood changes the format. The two checks that do gate are the ones that cannot be wrong.
_public_key_b64 returns None rather than raising on a malformed seed or a missing pynacl — a diagnostic that crashes on the malformed input it exists to describe is worse than one that stays quiet, and the length check above already covers that path.
The length check stays (it still catches a PEM, a hex string, a truncated paste) with its overreaching claim removed and a note saying which case it does not cover.
Also
…truncated — full description in #416.
feat(update): self-update — pull, deploy into the launch folder, relaunch (#415) (#417)
What
The console keeps the deployment current (#415): keel update (CLI) and the TUI Account menu's update entry — one service, two front-ends, fail-safe by construction. keel/commands/update.py is pure orchestration over subprocesses/HTTP; both front-ends render the same plan (render_plan_lines), run the same run (run_update), and ask the same typed gate (typed_update_gate — the CLI's own _require_interactive_confirmation, failing closed off a TTY).
latest_release(fetch=...)— the public GitHub releases API, no auth, no secrets; honest errors for network, rate-limit and unexpected payloads.plan_update(release, ...)— current vs latest (semver; a release supersedes its own pre-release, junk tags refuse rather than guess), the four production wheel assets, theRelease/dir under the launch folder, everykeel*.dbto back up, the RUNNING venv, and offered/refused with a reason for every refusal.run_update(plan, *, echo, confirm_gate, download/install/migrate/verify=...)— the runbook's manual procedure, in its order, each step streamed, each failure honest.relaunch_tui(venv, argv)— the execv closure (pure construction; the new venv'skeelentry carrying the original TUI argv, falling back to[keel, tui]).
The fail-safe contract
- The gate runs inside the service —
run_updatetakesconfirm_gateand calls it before any mutation: there is no ungated path to the writes. Never automatic, always typed, fails closed off a TTY. - Backups first — every
keel*.dbin the launch folder is copied to<db>.bak-before-<version>-<ts>before anything downloads or installs (pinned by a test that asserts the backups exist from inside the install seam), and backups are never deleted. - The four production wheels only —
keel_core,keel_broker_api,keel_broker_coinbase,keel_traderby exact name, neverRelease/*.whl: the fake and Robinhood wheels can never ride along (RELEASING.md's rule). - Install by path into the RUNNING venv via uv (uv is a deployment dependency of self-update; an absent uv is an honest error naming the manual procedure), then
keel migrate --dbeach database with the new build. - Verify before cleanup — the new venv's
keel versionsmust report every keel distribution at the target; only a verified success removes the superseded wheels. - A failed verify is loud, never papered over — pip has already replaced the packages, so the updater says exactly that state, re-installs the previous wheels best-effort while they remain in
Release/(they do — cleanup only happens on success), and names the manual recovery.
Refusals and the relaunch split
Dev/source checkouts are refused (not a release build; nothing installed; or the running keel package resolving from the launch folder itself — the uv run keel case). The TUI relaunches itself on a verified success (os.execv the new build's entry, terminal restored first, original TUI argv carried); the CLI prints the command and does not relaunch anything.
The audit surfaces
…truncated — full description in #417.
feat(brokers): report robinhood's own sizing bounds on the preview (#418)
First half of #410. Reported, never enforced — see the boundary section below for why that split is the design and not a shortcut.
The gap
trading_pairs carries min_order_amount, asset_increment and max_order_size, and nothing read them — get_trading_pairs was one of two transport methods the adapter never called. A sub-minimum or off-increment order found out at the venue.
preview_order now reads them, reports what they say through Preview.errors, and puts the bounds themselves in Preview.detail, so the human at the confirm gate sees the number an order was measured against rather than a bare sentence.
The denominations are not the same
This is the part that bites, established live on 2026-08-19 across all 89 pairs:
| symbol | price | min_order_amount |
as QUOTE | as BASE |
|---|---|---|---|---|
| BTC-USD | 68,367.13 | 0.1 | $0.10 | $6,836.71 |
| ETH-USD | 2,098.51 | 0.1 | $0.10 | $209.85 |
| DOGE-USD | 0.072886 | 0.1 | $0.10 | $0.01 |
All 63 pairs that carry the field report the same 0.1. A constant cannot be a base-denominated minimum across six orders of magnitude of unit price — it is a venue-wide $0.10 floor in quote currency.
The obvious implementation, base_size >= min_order_amount, would reject every BTC order under 0.1 BTC (~$6,800), the exit path included — a far worse failure than the missing check this issue exists to add.
max_order_size and asset_increment go the other way, both base: max_order_size varies per asset (20 BTC, 6,500,000 DOGE) and only lands on a comparable notional ceiling ($1.37M, $474k) read that way. The names carry it once pointed out — amount is quote, size is base — which is why _PairRules says so at the top and detail labels each key with its denomination.
The boundary: reported, never enforced
place_order does not call this, and test_place_order_does_not_consult_the_sizing_bounds pins that it does not. Two reasons, and the second decides it:
- Every order this adapter can place is an exit or a protective leg — entries are
MarketIOCByQuote, which this venue cannot express at all. A check that refuses on those paths can strand a position or leave one running without its stop. - Nobody has ever watched this venue reject an out-of-bounds order, because no order has ever been placed against it (#412). Refusing locally would be a guess, and a guess that refuses an order the venue would have accepted is a new failure this package invented.
The off-increment note says outright that the outcome is unobserved rather than predicting one. Enforcement can be revisited once #412 has seen a real rejection.
Absences stay absences
Three cases that must not collapse into a passing check, one test each:
- a pair with no
min_order_amount(26 of 89, #230) → says the minimum was not checked,detailreadsunknown, not0 - a
trading_pairscall that fails or returns nothing → says the bounds were not checked - an unpriced market order → not accused of being below a minimum; its zero
quote_sizeis an absence, not a number
symbol= is passed so the venue filters. #230 is the standing lesson: the probe that read results[0] off the unfiltered 89-row response got BILL-USD and concluded the venue publishes no minimum at all.
…truncated — full description in #418.
feat(brokers): give the port an idempotency key so a placement retry is safe (#419)
Closes #409.
The defect
Every adapter minted a fresh uuid4 client_order_id per place_order call, so no retry was ever deduplicated. A caller retrying after a timeout — exactly when the first request may already have reached the venue — placed a second live order, because the retry carried a different id and the venue had nothing to match it against.
Robinhood and Alpaca documented the hazard. Coinbase's docstring asserted the opposite:
Place a live order. A fresh
client_order_idper call gives Coinbase idempotency.
That had it backwards. A per-attempt id is precisely what withholds idempotency — it leaves the venue nothing to deduplicate on.
Why it stayed a comment
The port could not express the difference. place_order(spec) cannot tell a retry of one intent from two orders a strategy genuinely meant to place. So the port now carries it:
def place_order(self, spec: OrderSpec, *, idempotency_key: str | None = None) -> PlaceResultNonemints per attempt — the unchanged default. The opposite default is not safer: an id derived from the order would collapse two deliberate orders into one, and a position at half the intended size is as wrong as one at twice it.- A key resolves every attempt under it to one venue-facing id.
The derivation, and why it hashes
keel_broker_api.port.resolve_client_order_id owns it, and turns the key into a uuid5 rather than passing it through:
resolve_client_order_id(None) # -> a fresh uuid4, one per attempt
resolve_client_order_id("cycle-7:pos-3:exit") # -> the same uuid5, every timeThe venues do not agree on what a client order id may be — Robinhood's is a UUID, Alpaca's a string of up to 128 characters. Hashing lands on the intersection, so a caller uses whatever natural key it has (a cycle id, a position id, a leg name) without knowing which venue the order will be routed to. One derivation in one place: an adapter inventing its own rule would make the same key mean different orders at different venues.
uuid5 is deterministic across processes, which is the case that matters. A retry issued by a new process — what a crash produces — derives the same id as the attempt that crashed. An in-memory table of "ids I already sent" cannot cover that.
The namespace is pinned by a test that writes the expected UUID out rather than recomputing it from the implementation. Recomputing would pass just as happily if the namespace changed, and a changed namespace silently makes every previously-derived id unreachable — which is the deduplication this mechanism exists to provide.
Threading
| adapter | |
|---|---|
| robinhood | key → to_order_body(client_order_id=...) |
| coinbase | key → create_order(client_order_id=...), backwards docstring corrected |
| alpaca | key → to_order_body(client_order_id=...) |
| fake | accepts and deliberately ignores — it has no client_order_id to carry it into, and a dedup table there would make the stand-in behave better than the venues it stands in for |
Conformance
…truncated — full description in #419.
feat(brokers): retry robinhood GETs on 429 and 5xx, never POSTs (#420)
Closes #411. Unblocked by #419 — see the POST section.
The gap
Robinhood allows 100 req/min sustained, 300 burst, and the transport had no backoff at all: a 429 propagated as a bare HTTPError alongside 401 and 5xx.
The limit is reachable in ordinary use, not just under abuse. get_fee_summary alone can spend 21 requests in one call (_MAX_PAGES + the account read), and preview_order now adds a trading_pairs read on top (#418). A "wait half a second" was turning into a failed cycle.
What retries
| GET, 429 / 500 / 502 / 503 / 504 | retried, up to 4 attempts, 0.5s doubling, capped at 8s |
| GET, 404 | not retried — it is an answer, and the 404→None split stays as load-bearing as ever |
| GET, 401 | not retried — a signature or clock problem does not heal in half a second; retrying turns one clear failure into four identical ones |
| POST, anything | never retried — see below |
Retry-After wins where the venue sends one, since it knows when its window resets and we do not — but it is clamped, not obeyed. Retry-After: 3600 is a legal response, and honouring it literally would park a trading loop for an hour inside what the caller believes is a bounded read.
Only the delta-seconds form is read. The HTTP-date form is legal and deliberately ignored: honouring it means trusting the server's clock against ours, and a skewed clock gives either an instant retry (useless) or a very long sleep (worse), while exponential backoff is already correct without any clock at all.
A retryable status that survives every attempt still raises, so a persistent quota problem stays visible rather than becoming a hang.
Why a POST is never retried
This is the decision, not the unfinished part.
A 429 or 5xx on create_order is an unknown outcome, not a refusal — the venue may have accepted the order before the response was lost. Sending it again places a second live order unless both attempts carry the same client_order_id, and this layer cannot tell whether they would: the body arrives already built, and an id derived from a caller's idempotency key (#409) is indistinguishable from a freshly minted uuid4 from here.
Now that #409 exists, a caller can make a placement retry safe. So the retry belongs where that knowledge lives — above the adapter, not inside the transport. A transport that retried POSTs would be guessing, on the one request where guessing costs money.
test_a_POST_IS_NEVER_RETRIED_even_on_a_429 asserts one call and zero sleeps, with the reason in the docstring.
Signing moved inside the loop
The signature is valid for 30 seconds from its timestamp. A retry that waited out a backoff and presented the first attempt's signature would come back 401 — a failure invented by retrying, and indistinguishable from a bad credential.
Pinned by a test that advances the clock between attempts, because two attempts in the same wall-clock second sign identically and would prove nothing.
Testability
sleep is injected, and _transport() in the tests defaults it to a no-op. Without that, every existing test touching a 429 or 5xx would have waited out real backoff — the first run of this change took 11.8s instead of 1.3s for exactly that reason.
…truncated — full description in #420.
feat(scripts): a fenced one-order probe for the shapes only a real order reveals (#421)
Refs #412. The instrument, not the observation — #412 stays blocked on a trade-scoped credential.
Why
robinhood_smoke.py closes everything a GET can close. What it cannot reach is the half of the adapter that only exists once an order does: the placement response's state values, the per-order fee_charged that get_fee_summary sums, whether a cancel 200 actually confirms, and every field name in the four order fixtures — all transcribed from documentation, none corroborated by a live response.
No sandbox exists, and the conformance suite must never see live credentials. One real order is the only way, so this is the smallest and most heavily fenced version of it: a limit buy far below the bid, polled, then cancelled.
The fences
- Refuses without
--place. Default prints the exact body and stops. - Exactly one order-creating POST per process, enforced at the request layer.
_OneOrderOnlyrebinds_requestrather than wrapping — a__getattr__wrapper is bypassed by the transport's own internal calls, the traprobinhood_smoke._ReadOnlydocuments. - Refuses any discount under 25% of the venue's own best bid. 25 and not 1 because
best_bid_ask's legs are sampled independently and cross by up to ~1.4 bps on tight pairs (#413) — the fence has to survive a quote that is slightly wrong either way. - Refuses a notional over
--max-notional(default $10), so the amount at risk is bounded by a number the operator typed. - Checks the venue's own bounds first — below
min_order_amount(quote-denominated, #410) or offasset_increment, the venue would reject and the run would learn nothing. - Cancels in a
finally. An exception between placement and cancellation must not leave a live resting order behind; this process holds the only handle on it. - Passes an
idempotency_key(#409), so a re-run after an ambiguous failure is one order at the venue.
A failed placement is classified, not raised
| 400 / 401 / 403 / 404 / 422 | the venue refused this request — certainly no order |
| anything else (5xx, timeout, dropped connection) | UNKNOWN — silence is not refusal |
The unknown branch names the client_order_id and says to go and look, because that id is the only handle on an order this process never saw. Reporting silence as "nothing happened" is how a probe leaves a live order behind and tells the operator it did not.
A bug the tests caught
Recorded responses must preserve quoting — Decimal (arrived unquoted) back as a number, str (arrived quoted) still a string — because this venue is inconsistent about it in the same object (#217 F6).
The obvious implementation is silently wrong: json renders floats with float.__repr__, not repr(o), so a float subclass carrying the original digits is bypassed and Decimal("0.00000001") is written 1e-08. That is the malformed-number hazard translate._render exists to prevent — baked into a fixture, which is exactly the class of wrong claim #414 and #230 were. Digits now go out as a sentinel string and are unquoted textually, with a guard refusing to rewrite a payload that already contained the sentinel. Both pinned by test.
Run against the live venue today
Twice. Both returned:
403 {"type":"client_error","errors":[{"detail":"You do not have permission to perform this action.","attr":null}]}
…truncated — full description in #421.
chore(release): 0.10.0 (#422)
Step 1 of docs/RELEASING.md: the version bump, in a reviewed PR, because the release workflow refuses to change the version itself.
Why minor and not patch
Three things since v0.9.3 change what an implementor or a deployment can rely on:
- A new distribution.
keel-broker-alpaca(#382, #384) and the paper-equities profile that selects it (#386) — a deployment can now be US equities via the broker port, not crypto only. - The port contract moved twice.
market_clock/market_schedulemade venues session-aware (#385);place_ordergainedidempotency_key(#419). Both carry defaults so no caller breaks, but a third-party adapter that doesn't accept them is no longer aBroker— the conformance suite now says so. A patch bump must not hide that. - The operator console. The TUI became keel's console across #399–#408, and
keel update(#415/#417) makes a deployment self-updating.
Every pin moves with it
All seven distributions, and the == cross-pins:
keel-trader 0.9.3 -> 0.10.0 (+ 3 pinned deps)
keel-core 0.9.3 -> 0.10.0
keel-broker-api 0.9.3 -> 0.10.0 (+ 1)
keel-broker-coinbase (+ 2)
keel-broker-alpaca (+ 2)
keel-broker-robinhood (+ 2)
keel-broker-fake (+ 2)
The four production distributions are required == at the same version (RELEASING.md, "Release assets"), so a bump that missed one installs a mixed set — the keel-trader 0.5.7 against keel-core 0.5.5 failure keel versions exists to catch, and which ~/keel actually ran across two releases. No 0.9.3 remains anywhere in the workspace.
Verified
Full suite 3797 passed, 3 skipped. ruff check clean.
After merge
Actions → Release → Run workflow, entering 0.10.0. The workflow validates the input against pyproject.toml, runs tests + ruff, stamps the commit, builds all packages, installs into a clean venv by path and asserts a clean [release] identity, verifies the live config asset is mode: confirm, tags v0.10.0, and publishes.
One stale doc, not fixed here
RELEASING.md's asset table predates keel-broker-alpaca — it lists six distributions and describes the fake and Robinhood wheels as published-but-not-deployed, with no row for Alpaca. uv build --all-packages will build and publish it regardless, and keel versions discovers distributions from installed metadata rather than a hardcoded list, so nothing is functionally wrong. Worth a follow-up rather than scope in a release bump — particularly since keel update downloads exactly four production wheels, which means the equities profile is not self-updatable today.
chore(release): update uv.lock to 0.10.0 (#423)
Fixes the failed 0.10.0 release run: https://github.com/CodeGateSoftware/keel/actions/runs/32316126013
What happened
#422 bumped the version in all seven pyproject.toml files and missed uv.lock, which is tracked and still pinned every workspace member at 0.9.3.
The workflow's "Sync dependencies" step regenerated it, which left the checkout dirty. "Stamp build info" then stamped a build from that dirty checkout, and keel --version reported:
warning: this build is NOT reproducible -- it does not correspond to a commit. Do not run it against live funds.
keel 0.10.0+29464f06ee11 (DIRTY) [release]
::error::artifact reports a dirty tree
The guard did exactly its job. version.build_info() re-checks git status --porcelain against the stamp rather than trusting it — the "believe git over a stale stamp" branch — and a (DIRTY) build corresponds to no commit, which RELEASING.md says must never run against live funds. Lint, Type-check, Test and Build all passed; nothing was tagged and nothing was published.
The fix
uv lock. Seven lines, one per workspace member:
Updated keel-broker-alpaca v0.9.3 -> v0.10.0
Updated keel-broker-api v0.9.3 -> v0.10.0
Updated keel-broker-coinbase v0.9.3 -> v0.10.0
Updated keel-broker-fake v0.9.3 -> v0.10.0
Updated keel-broker-robinhood v0.9.3 -> v0.10.0
Updated keel-core v0.9.3 -> v0.10.0
Updated keel-trader v0.9.3 -> v0.10.0
No third-party dependency resolution moved — the diff is 7 insertions, 7 deletions.
Verified by reproducing the CI condition
With the lock committed, uv sync now leaves the working tree clean. That is the precise condition that failed: the release stamps from a checkout that has just been synced, so uv sync leaving anything modified is what makes the artifact unreleasable.
Follow-up worth considering
The lockfile is only checked implicitly, by the dirty-tree guard three steps later, and the error it produces ("artifact reports a dirty tree") does not name the cause. A uv lock --check immediately after checkout would fail in seconds with "the lockfile is out of date" instead of failing after Build with a message that sends you looking at the stamp. Happy to open that as a separate PR.