Skip to content

keel v0.10.0

Latest

Choose a tag to compare

@github-actions github-actions released this 20 Aug 00:14
· 9 commits to main since this release
24aa56f

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.

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

Configure

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

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

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

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_time
  • test_the_when_column_is_labelled_utc_so_the_frame_is_stated_not_inferred
  • test_the_age_column_answers_freshness_without_timezone_arithmetic
  • test_age_is_blank_not_invented_when_the_caller_has_no_clock
  • test_age_of_a_row_stamped_in_the_future_reads_now_not_a_negative_duration
  • test_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 under keel.brokers as alpaca, wired into the dev group (optional venue, same posture as robinhood), mypy strict from birth, Dependabot entry + pip-audit export exclusion.
  • FR-2 Venue identityvenue = "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 → fractional qty market, limit_gtc/stop_limit_gtcgtc limit/stop-limit. Fractional sizes render positionally (format(d, "f")), never scientific notation. extended_hours: false is 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 is GET /v2/stocks/{sym}/quotes/latest; best_bid/best_ask ride in Preview.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 in Preview.errors, never as a price.
  • FR-5 Market data15Min/1Hour/1DayFIFTEEN_MINUTE/ONE_HOUR/ONE_DAY; every other granularity refused with ValueError (the port's sanctioned refusal). Pagination follows next_page_token, bounded like the sibling transports. Data tier is a declared capability: constructor-validated iex|sip, sent on every data request — never the venue's silent sip default.
  • 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 map qty_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.py as 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 feeds Preview.est_fee on 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.

  1. [major] NaN/Infinity coercion hole_decimal_or_none parsed 'nan'/'Infinity' successfully (JSON NaN arrives via parse_constant), crashing preview_order at bid > 0 and quote_size / crossed, get_balances at min(buying_power, cash), and silently poisoning Candle OHLCV — contradicting the preview docstring's populate-errors invariant. Now is_finite()-guarded; three red-first tests cover quote sides, balances, and bar leaves.
  2. [minor] Host-map-only construction — the dead trading_host=/data_host= overrides (zero callers anywhere) are REMOVED; TRADING_HOSTS is 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).
  3. [minor] Quoted URL path segments — symbol/order-id interpolations percent-encoded (safe="") at all four sites; A/B-USD and a crafted order id can no longer reshape the request (asserted on the outgoing URL).
  4. [nit] Naive timestamps refused — offset-less RFC3339 no longer silently read as local time; ValueError per the module's refusal convention.
  5. [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 declare False (24/7); alpaca declares True. 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; SessionState in results.py: open | closed | clock_unavailable). Alpaca reuses Phase A's /v2/clock read — is_market_open() now delegates to market_clock() so the two can never disagree. The 24/7 adapters answer OPEN as 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 — logging agent.cycle_skipped with reason: market_closed (INFO; a weekend is expected) or the distinct reason: 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 via agent.recorded_market_closed: stale-but-present series render as the distinct CLOSED state and do NOT count as missing/stale for the exit code — a weekend must not page an operator. Deliberate carve-outs: missing still alerts (a closed venue serves history); clock_unavailable does 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.yamlbroker: {name: alpaca, endpoint: paper, data_feed: iex}; market_data.granularities is 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 artifactscom.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-equities wrapper (config + keel-equities.db travel as a pair).
  • Runbookdocs/operator-runbook.md gains "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 --force with the documented bypass rationale, fetch warm — 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.example wires 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.pyassess_products (window-bounded sweep), render_freshness, run_fetch (check/skip/warm/repair flow, FetchResult)
monitor (b) extracted new keel/commands/monitor.pymonitor_cycle, run_monitor (FR-9 session-aware loop, once-per-state-change skip lines)
simulate (b) extracted new keel/commands/simulate.pyrun_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.pyscreen_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.pybuild_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, mirroring tui.py's pure-model/thin-loop split.
  • run_live gains three modes — menu (the PRD §3 tree, cursor + 1-9 ordinals), profile (the deployment menu), placeholder (a future slice's notice) — and m in normal mode opens the menu. Every screen, all ten modes, is prepended the two-line session banner (fail-soft read).
  • run_live takes the shell as an optional console_binding: a caller that passes none gets the pre-C2 dashboard byte-for-byte — which is what keeps every pre-existing test unchanged. --once heads 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 --enabled keeps the CLI's own gate — clis_typed_withdrawals_gate calls _require_interactive_confirmation("attest withdrawals as ENABLED", …) verbatim, rendered in-console via the curses suspend/restore dance, failing closed; --suspended stays 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 own reasons — the exact lines rules promote prints 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_fee provenance).
  • the insights service's promotion-gate distance for paper rules (build_rule_track_record/track_record, the trades_remaining wording).
  • the rows' own promoted_at/demoted_at stamps 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_gatereset_high_water_mark
resume-entries [typed] clis_typed_resume_entries_gateclear_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_gatedisengage_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, the withdrawals.py pattern), record_flow_action, parse_flow_amount, the four confirmation lines (KILL_ENGAGED_LINE, …), render_flow_recorded, render_blocked_entries
  • keel/commands/autonomy.pyautonomy_on_gate, autonomy_expiry, render_autonomy_on, AUTONOMY_OFF_LINE
  • keel/commands/db.pyvalidated_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 new DECLARED_ENDPOINTS/DECLARED_DATA_FEEDS on AlpacaAdapter — derived from TRADING_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 (+ --json following status --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 shared capability_facts wording, 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 pnl report 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 naming keel db import, never a confident total realized P&L: 0.
  • versions — the same lines keel versions prints, through ONE shared renderer extracted to keel/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 in help_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:

  1. 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.
  2. 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's next_status wording; the shared purification renderer; the executor's two READ helpers). Allowances are site-scoped and asserted to name real callees, so they cannot leak.
  3. Decimal is display-only — no arithmetic operator over a Decimal(...) construction.
  4. No broker construction outside the seams — only inside the build_broker/build_client lambda handed to a service (the run_fn seam) 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, no next/previous.
  • rh_best_bid_ask_uncrossed.jsonnew, 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:

  1. ROBINHOOD_API_KEY equals the public key of ROBINHOOD_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.
  2. ROBINHOOD_API_KEY is 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, the Release/ dir under the launch folder, every keel*.db to 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's keel entry carrying the original TUI argv, falling back to [keel, tui]).

The fail-safe contract

  • The gate runs inside the servicerun_update takes confirm_gate and 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*.db in 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 onlykeel_core, keel_broker_api, keel_broker_coinbase, keel_trader by exact name, never Release/*.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 --db each database with the new build.
  • Verify before cleanup — the new venv's keel versions must 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:

  1. 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.
  2. 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, detail reads unknown, not 0
  • a trading_pairs call 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_size is 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_id per 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) -> PlaceResult
  • None mints 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 time

The 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. _OneOrderOnly rebinds _request rather than wrapping — a __getattr__ wrapper is bypassed by the transport's own internal calls, the trap robinhood_smoke._ReadOnly documents.
  • 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 off asset_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_schedule made venues session-aware (#385); place_order gained idempotency_key (#419). Both carry defaults so no caller breaks, but a third-party adapter that doesn't accept them is no longer a Broker — 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.