Skip to content

keel v0.20.0

Choose a tag to compare

@github-actions github-actions released this 28 Sep 14:12
· 63 commits to main since this release
a7fe3fa

Built from a7fe3fa. Version binds to this hash:
keel --version reports keel 0.20.0+a7fe3fab46cf [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.20.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

feat(dca): keel dca plan -- propose a DCA schedule within the venue plan's cap (#846)

PR 1 of docs/superpowers/plans/2026-09-27-dca-plan.md (Tasks 1–6): the plan service and the keel dca plan CLI. The read-only web card (Tasks 7–8) comes in PR 2.

Behaviour

  • keel dca plan --budget <monthly USD> --buffer-pct <fraction> [--cadence-days 7] proposes a weekly DCA schedule.
    • The universe is allowlist ∩ admitted by screen_product ∩ positive target_weights, minus assets that already have a non-disabled dca rule. Weights are renormalised over that set.
    • Every excluded asset is listed with each reason that applies: not_on_allowlist, not_admitted, no_weight, or has_dca_rule. An asset with a DCA rule is shown as "existing, unchanged".
  • Spend is budget × (1 − buffer). It is checked against rail 14's monthly buy cap for the deployment's venue, read exactly as guards.check reads it. A parity test drives guards.check at the cap and one cent over it.
  • Rail 14 is stated as a monthly BUY cap, never a fee waiver (#836). "fee-free" appears nowhere.
  • Fees are estimated at the configured fees.taker_pct and labelled as such. The venue minimum order size is unknown to keel, and the output says so.
  • At a TTY the prompt is [Y] Approve / [E] Edit weights / [N] Cancel.
    • [Y] writes one candidate dca rule per asset through rules.add_rule_row, all or nothing (R2).
    • The command never promotes, never writes paper or live, and never touches an existing rule.
    • A blocked plan does not offer [Y].
  • Off a TTY it prints the plan, writes nothing, and exits 0 when the plan is approvable or 1 when it is blocked.
  • keel/commands/dca_plan.py is a pure service with no click, ready for PR 2's GET-only reader. keel/commands/dca.py is the thin click front-end.

Rulings in force

R1–R19 as in the plan, with these changes:

  • R7 amended (after #843). Each existing live DCA rule's monthly commitment uses that rule's own budget_usd, because the executor sizes each DCA buy from the rule's size_usd. The commitment is added to spend, and the plan warns, without blocking, when the sum exceeds the cap. A live rule with dip_bonus_pct > 0 gets its own warning, since its buys can exceed the listed amount.
  • R9 withdrawn. There is no "executor sizes DCA from config.dca.budget_usd" warning, and no executor_budget_usd field. A test pins the warning's absence.
  • #842 note. Nothing in the output describes max_exposure_usd as a DCA limit, and a test pins that.
  • R20 (new). Interactivity is decided once, first. Off a TTY the database is opened with _open_repo_ro (mode=ro), so the preview cannot create a missing DB file or migrate an old schema.
  • Write-loop sqlite3.Error (new). A locked database mid-write is caught and refused, and the refusal names the rule ids already written. This is R2's stated cost-if-wrong, now made explicit.

Test evidence

  • New files: tests/commands/test_dca_plan.py and tests/commands/test_dca_cli.py. tests/test_rail14_is_a_buy_cap.py gets one test, and SERVICE_MODULES gets two modules.
  • Each task was TDD red then green. Mutations were proven applied, then shown to be killed:
    • a write inside select_universe;
    • config budget used instead of each rule's own in R7;
    • rail 14 note removed from the render;
    • pre-validation loop removed from apply_dca_plan;
    • rail 14 no-record cap changed.
  • Fixtures are proven to reach the code under test: the fake screen_fn is called 3 times, the patched build_rule_from_params is called for PAXG, the patched add_rule_row is called twice, and _open_repo_ro is called once off a TTY.
  • uv run pytest -q: 6962 passed, 3 skipped, exit 0.
  • uv run ruff check keel tests exited 0, uv run ruff format --check keel tests exited 0, and bare uv run mypy exited 0.
  • Manual sanity run: the template config against a fresh migrated temp DB, off a TTY. It exits 1 with named blockers: every asset is unadmitted, and rail 14 is at $0 with no attestation, together with the attest command. The DB checksum is unchanged.

guards.py and executor.py are untouched.

feat(web): read-only DCA plan card on /rules (#850)

PR 2 of docs/superpowers/plans/2026-09-27-dca-plan.md (Tasks 7–8). This PR builds on PR 1 (#846) as merged, not on the plan's snippets.

What it does

  • GET /api/dca-plan?budget=&buffer= answers from the same service as keel dca plan (build_dca_plan). The admission screen is resolved per call.
    • It is read-only and writes nothing, which is pinned by a rules-row count after a request. It is GET only; a POST gets a 404.
    • Money crosses the wire as strings, values arrive presentation-ready, and state is an explicit field.
  • No budget: the route answers an awaiting_budget payload with the same keys as the ready one, every figure absent, and the command template.
  • The worst-month cap check (#847) is on the wire and on the card.
    • cap_check is the worst UTC calendar month rail 14 was compared against, with the checked total as its value.
    • It is judged bad over the cap, good within it, and neutral when the tier is unlimited.
    • The card shows it as "checked against the cap: worst calendar month for a 7-day cadence, 5 buy day(s) x $103.47 per cycle = $517.35".
  • The card on /rules sits under the rule ledger and reads the page's own address: /rules?budget=500&buffer=0.1.
    • It shows the summary figures, the per-asset schedule, the excluded assets with their reasons, the existing DCA rules (unchanged), the blockers, the notes, and the exact CLI command with --config/--db for the served deployment.
    • It has no button, anchor, form or input, and no event handler.
  • A refused read (a bad ?budget=, or a config the service refuses) leaves the ledger standing. The card says it could not read the proposal and places the server's reason, in the service's own words.

Rulings

  • R21: every DcaPlanError is a 400 ApiRefusal. This includes the ones build_dca_plan raises over the config (target_weights keys colliding by case, a non-finite weight).
    • Why: ApiRefusal is this module's only way of saying "declined, and here is why". The CLI refuses the same error as a clean message. A 500 would read "That report could not be built" over a message that already names the fix.
    • Cost if wrong: a config problem is reported with a client-error status. The body still names the config key to fix.
  • R22: the worst-month sentence is written once, by a new service function dca_plan.worst_month_text(plan). The CLI's checked against the cap: line and the payload's cap_check.display both use it.
    • Why: two front-ends wording, or re-totalling, the figure the blocker compared is the #847 drift.
    • Cost if wrong: a one-function change to PR 1's module. The CLI line is byte-identical, and a test pins that.
  • R23: the card receives the secondary reading's error as well as its data (rulesView(data, sort, onSort, plan, planError)).
    • Why: with data alone, a 400 would show a bare "could not be read" and throw away the reason, such as the fraction hint for buffer=10.
    • Cost if wrong: one extra parameter.
  • R24: no service-worker cache bump. The worker's cache name is keel-shell-<full_version>, which is <version>+<commit>, so every new commit is a new cache. /api/ is never cached, and a new test pins that every API_ROUTES path sits under API_PREFIX.
    • Why: earlier web PRs changed static JS without a bump for the same reason.
    • Cost if wrong: none; the mechanism is structural.
  • R25: the card's three row collections (buys, excluded, existing) are enrolled in the row-key scan, not exempted. A seeder patches the screen to admit BTC and reject PAXG, and writes an existing ETH DCA rule. The endpoint is asked with a budget, so every collection has a row.
    • Why: the plan said not to use an exemption.
    • Cost if wrong: _seed_for now takes monkeypatch.

Test evidence

  • uv run pytest -q: 7021 passed, 3 skipped (exit 0).
  • uv run ruff check keel tests: exit 0.
  • uv run ruff format --check keel tests: exit 0.
  • uv run mypy (bare): exit 0.
  • node --check on render.js and main.js: exit 0 for both.
  • Structural client tests:
    • The card's tags are on a closed list, with a scan that sees a mutated-in button.
    • Exactly one code element, paired with plan.command.
    • One table, with no sort pair.
    • Every served key and summary figure is paired with a read.
    • Exactly one figure reads plan.cap_check, under "checked against the cap".
    • The ROUTES row, and the mount call passing both the reading's data and its error.
    • The page-address seeding: one location.search read, names budget/buffer only, and the dca-plan bag only.
  • Mutations applied and killed, each confirmed to have changed the source before the run:
    • A module-level screen_product import fails the route tests (the patch no longer reaches the reader).
    • Removing the DcaPlanError catch fails the config-refusal test.
    • Renaming row keys (row.fee, item.reasons, rule.per_buy) fails the row-key scan.
    • Dropping the cap_check figure, dropping a table cell, dropping planReading.error, widening the seeded names, and not placing error.detail each fail their test.
  • Browser check (Playwright): a real keel serve on a copy of keel/templates/config.yaml with a temp DB, on a free port. The service worker was unregistered and its caches deleted first.
    • /rules?budget=500&buffer=0.1 shows the blocked state with the real screen: nothing admitted on an empty DB, every asset listed as excluded with the screen's reasons, and the exact command.
    • /rules with no query shows the awaiting state and the command template.
    • Both loads had 0 console errors or warnings, 0 controls in the card, and 1 code element. The rule ledger stays in place.
    • At 390px there is no horizontal scroll.
    • buffer=10 shows the 400 detail ("…0.1 means hold back 10%") under the standing ledger. The only console entry is the browser's network log of that 400.
    • A second server with the screen patched to admit everything and a $600 attested cap showed the populated 7-row schedule, "ready to apply from a terminal", and the worst-month check $517.20 judged good.

Deviations from the plan

  • The payload has one more key than the plan's shape: cap_check, the #847 amendment.
  • rulesView takes a fifth parameter, planError (R23).
  • The card's excluded and existing lists are built with .map over explicit keys, so the row-key scan checks them. The plan had one generic list helper that read every key.
  • The empty-table text is "No buys planned." rather than "No asset is eligible.", which was false in the awaiting state.
  • A small .dca-plan .dca-command CSS rule shows the command as a selectable block.

chore(deploy): sync live-rules.json with the live rule set (#855) (#858)

Closes #855

Regenerates deploy/live-rules.json from the live rule set. I made a read-only copy of ~/keel/keel-live.db with Python's sqlite3 backup API (source opened mode=ro) into a scratch dir, and ran scripts/rule_manifest.py export against that copy. Nothing under ~/keel was written.

Live DCA rules (status live)

id product budget per buy cadence
6 BTC-USD $40 7 days
8 ETH-USD $25 7 days
9 PAXG-USD $25 14 days
10 ADA-USD $15 14 days
11 XLM-USD $15 14 days
12 DOGE-USD $15 14 days
13 FET-USD $15 14 days

All seven have dip_bonus_pct 0 and lookback_days 90.

The turtles are paper. The six turtle_breakout rules (BTC, ETH, PAXG, ADA, XLM, DOGE) are status: paper. The old manifest listed five of them as live.

Rulings

  • Updated test_committed_manifest_is_valid. It pinned exactly one DCA rule and pinned that rule's params to Dca's constructor defaults. The regenerated manifest breaks both: there are 7 DCA rules, and none uses the default $50. The test comment itself says to update it when this happens. It now asserts:
    • every DCA rule is live;
    • no rule is candidate;
    • every DCA rule differs from the constructor defaults on at least one param, so a keel init reseed would show as a change in value, not only a change in status.
    • Why: the brief expected the test to pass as-is (#843/#844 dropped the budget pin), but the count pin and the defaults-coincidence pin were still there.
    • Cost if wrong: one test's assertions need re-tightening. No runtime effect.
  • Refreshed one sentence in scripts/rule_manifest.py's module docstring. It said the live DCA rule is 50 on purpose, matching the default. That is no longer true.
    • Why: keel keeps its rules in docstrings, and a stale one misleads.
    • Cost if wrong: none.

Test evidence

  • uv run pytest -q: 7027 passed, 3 skipped (exit 0)
  • ruff check keel tests scripts: exit 0
  • ruff format --check keel tests scripts: exit 0
  • mypy: exit 0
  • Mutation checks. For each, I confirmed the file differed from a saved copy before running, then restored it from that copy:
    • set BTC budget_usd to "50", so the rule equals the defaults: the value assertion fails, naming BTC-USD;
    • set one DCA rule's status to paper: the status assertion fails.

docs(spec): DCA sleeve sell side -- trims, rebalancing, reverse DCA, protective exits, rotation (#857) (#859)

Design spec for #857, docs only. The issue stays open for the operator's review; nothing here is implemented.

Spec: docs/superpowers/specs/2026-09-28-dca-sleeve-sell-side-design.md

What it decides

One architecture for five sell-side features that all sell from the same unbracketed DCA holdings:

  • Action.REDUCE and Rule.reduce_signal. Today Rule can only express "buy" or "sell all", position_rule:<product> has one owner (dca, whose exit is hardcoded False), so no live path can sell a DCA tranche at all. Every sell-side feature becomes a named rule kind (profit_take, band_rebalance, reverse_dca, sleeve_exit, rotation) on a rule row, which is what satisfies rail 10 and gives the audit trail its vocabulary.
  • Lots and average entry come from the positions ledger (FIFO, entry fees in the basis), never from orders; two doctor findings surface drift between the two and against the venue.
  • Rails per leg. No rail is added, relaxed or DCA-exempted for sells. Rail 2 gates sells too, so a whole-sleeve sale above max_per_order_usd is sliced one leg per cycle. The redeploy BUY leg of a rebalance or rotation meets rails 8 (no averaging into losers) and 14 (the monthly buy cap that now bounds DCA) as a non-DCA buy.
  • A separate sells gate. Sell-side rules ship execution: preview; placing needs a typed yes at a TTY per proposal, or execution: auto plus a new keel autonomy on --sells window. The live autonomy flag does not release the new sell class. No web or MCP verb.
  • Audit: a sell_proposals table for every evaluated reduction, placed or not, chained into audit_events; trade_outcomes.is_dca derived from the tranches so rail 16 never counts a sleeve sale.
  • Promotion: a sleeve_sell class with a proposal-replay description and a 60-day paper period, because these rules have no R for the backtest gate.

Per-feature verdicts

# Feature Verdict
1 Profit-taking and trimming preview-only: keel dca trim --preview; the drift trigger is #831's arm D (not better); the gain-over-entry trigger is untested and would be accumulation policy's 4th pre-registered trial
2 Steering and band rebalancing don't build (yet): #831 arms C and D, pre-registered and bootstrapped, are not better than static DCA after fees; the read-only bands view is folded into the trim report
3 reverse_dca build, in preview: a spend plan, not an edge claim; gates on price floor, drawdown, floor quantity, same-day DCA; refuses to go live beside a live dca on the product unless typed
4 Protective exit monitor preview-only alerts on level transitions, after #811 and #799 land; never an automatic sell, since a structural stop negates the sleeve's accumulate-through-drawdown thesis
5 Rotation and tax-loss harvesting don't build (yet): no jurisdiction anywhere in the repo, so no tax number can be computed; the KB flags sale-and-buyback as a fabricated round trip (§65.11), recorded as a fiqh finding; build keel pnl --lots and #798's positions close instead

Evidence the requests ran into

  • Requests 1 and 2 were measured by #831 (docs/experiments/2026-09-27-accumulation-policy.md): no arm beats static DCA under D6; band trimming's +13.5% on the one historical path is taken back by the bootstrap (36–43%).
  • The request's 1.20% fee is the sim figure; live pays 0.9% taker. Live gates use the venue's previewed commission with 1.2% as the recorded fallback.
  • Rail 14 is a buy cap and never touches a sell, but the redeploy leg spends it.
  • #811's doctor findings are not implemented yet; #799 is a recording defect, not the monitor gap. Both are prerequisites for feature 4.

Refs #857. Not "Closes": the operator reviews the recommendations and the twelve open questions in §12.

feat(dca): a cap-blocked plan names the largest passing budget and buffer (#854) (#867)

Closes #854

When rail 14's worst-calendar-month check blocks a plan, the blocker now names two values:

  • the largest --budget that passes at the given buffer;
  • the smallest --buffer-pct that passes at the given budget.

One service function, _passing_suggestion_text, builds that clause. plan.blockers carries it, and the CLI (render_dca_plan) and the /rules card (dca_plan_payload) both show it unchanged.

The issue's worked example ($500 budget, 0.10 buffer, weights .4/.3/.3, 7-day cadence, $500 cap) now reads:

planned spend $450.00/month; the worst calendar month for a 7-day cadence holds 5 buy day(s), which at $103.47 per cycle is $517.35 -- that exceeds rail 14's monthly buy cap $500.00 on coinbase; the largest --budget that would pass is 483.26; the smallest --buffer-pct that would pass is 0.1302

How

  • _per_buy_usd is factored out of build_dca_plan's loop. _worst_month_spend_for is the whole R5 pipeline: spend rounded down, each per-buy rounded down, summed, then multiplied by the worst month's buy days.
  • Two searches find the values: one over cents for the budget, one over 0.0001 steps in [0, 1) for the buffer. Each search calls that same pure function, never a closed-form inverse, so a suggestion cannot drift from what a re-run computes. Each boundary is checked on both sides.
  • DcaPlan.max_passing_budget_usd and DcaPlan.min_passing_buffer_pct hold the values (or None). The text is built from those fields, and they are formatted with format(x, 'f'), so they never print with an exponent.
  • New ruling R20 is in docs/superpowers/plans/2026-09-27-dca-plan.md and in build_dca_plan's docstring.

Rulings

  • Existing live commitments (R7) are reserved before the search. The searches subtract the existing live DCA rules' own worst-month commitment from the cap first, and the clause names that reservation whenever it is above zero.
    • Why: the brief says to include R7. The R6 blocker itself compares only this plan against the cap; R7 only warns. So the suggestions are stricter than the blocker.
    • Cost if wrong: when live rules exist, the suggested budget is lower than what would clear the blocker alone. The operator can still go higher and accept the R7 warning.
  • Deviation: "one step past is blocked" holds only when there are no live rules. With live rules present, one cent past the suggestion clears the blocker but raises the R7 combined-total warning. The test asserts exactly that.
    • Why: making R7 a blocker would reverse the operator's R7 ruling (warn, don't block).
    • Cost if wrong: if the operator wants a strict blocker boundary here, the clause should drop the reservation. That is a one-line change.
  • Suggestions also appear on the degraded-cap branch (cap unattested or lapsed, $0).
    • Why: the brief names "cap $0" as a case that must say "no buffer passes".
    • Cost if wrong: extra text beside the "attest" instruction.
  • When nothing passes, the clause says so in words. It reads "no positive --budget would pass" or "no --buffer-pct below 1 would pass" instead of giving a number.
    • Why: this is in the brief.
    • Cost if wrong: none.
  • The searches keep internal asserts on their boundary.
    • Why: monotonicity is proven in the docstring. The assert turns any rounding surprise into a loud failure rather than a wrong suggestion.
    • Cost if wrong: a surprise would give the web reader a 500 instead of a slightly-off number.
  • payload.py is unchanged.
    • Why: the card already passes plan.blockers through unchanged, so the shared text reaches it for free.
    • Cost if wrong: if the card later wants the values as separate fields, that is a follow-up.

Test evidence

  • The worked example pins 483.26 and 0.1302.
  • The re-run proof calls build_dca_plan at each suggested value and asserts approvable. At one cent more on the budget, or 0.0001 less on the buffer, it asserts exactly one blocker, and that the plan carries the same suggestion.
  • With a live rule: the suggestions pin 434.94 and 0.2172. At the suggested budget, neither the blocker nor the R7 warning fires. One cent more raises the R7 warning. Replaying the suggestion computed without the live rule also raises the R7 warning, which shows the reservation changes the result.
  • The two "nothing passes" cases each have a test: existing commitments already over the cap, and a $0 cap with a large budget.
  • A test checks that the values never print with an exponent.
  • Mutation check: I made the budget search return one cent too many and confirmed with cmp that the file changed. Three tests failed, including the re-run proof (the plan was still blocked at 483.27). I restored from a saved copy and cmp confirmed it was identical.
  • Full checks after the final commit:
    • uv run pytest -q: 7033 passed, 3 skipped (exit 0)
    • ruff check keel tests: exit 0
    • ruff format --check keel tests: exit 0
    • mypy: exit 0
    • no JS changed.
  • Not strict TDD: the worker got the pinned numbers from a standalone spike instead of watching those tests fail first. The re-run tests don't depend on those numbers, and the mutation check shows they fail when the search is wrong.

Touches keel/commands/dca_plan.py, which #853's PR also edits. Expect a small merge conflict near the blocker code.

fix(rails): exempt DCA buys from rail 6; dca plan checks rails 3 and 5 (#853) (#869)

Closes #853

DCA buys are now exempt from rail 6 (per_asset_concentration_cap), just as #842 exempted them from rail 4. Rail 14 (the monthly BUY cap, not a fee waiver) and available cash are now DCA's aggregate bounds. keel dca plan now checks the rails it can evaluate offline that still bind DCA.

Changes

  • keel/execution/guards.py: rail 6 fires only for is_buy and not intent.is_dca, mirroring rail 4. I updated the module docstring and the inline comment block. DCA holdings still count in the exposure that rails 4 and 6 measure for rule trades.
  • keel/sim/account.py: SimAccount.can_open has the same exemption, so keel simulate models live, and its docstrings are updated.
  • keel/commands/dca_plan.py:
    • Rail 3 (per day) blocker, R21. The busiest day is the planned per-buys plus every existing live DCA rule's own budget_usd, compared against caps.max_per_day_usd. per_day_cap_text builds the sentence.
    • Rail 5 (correlated) warning, R22. A per-buy above max_per_order_usd x CORRELATED_SIZE_SCALE, for an asset outside UNCORRELATED_ASSETS, raises a warning. Both constants are imported from guards.py. correlated_size_text builds the sentence.
    • The CLI (render_dca_plan) and the /rules card (dca_plan_payload) both show plan.blockers and plan.warnings unchanged.
  • Docs:
    • new docs/rails/rail-6-per-asset-concentration.md, in the rail-4 page's format, with the exemption table and a note that DCA holdings still count;
    • updated the rail 4 and rail 14 pages, docs/operator-runbook.md, and the plan doc (the #842 note amended, R21/R22 added);
    • updated the caps: comments in keel/templates/config.yaml and keel/templates/config.live.yaml;
    • re-synced root config.yaml; cmp shows it is byte-identical to the template.

Rulings

  • Rail 6 uses rail 4's gate exactly.
    • Why: #841/#842 set the operator-approved pattern for this exemption.
    • Cost if wrong: DCA per-asset size is bounded only by rail 14 and cash.
  • The rail 3 busiest day is the plain sum of all planned and live per-buys, with no phase search.
    • Why: Dca.detect is epoch_day % cadence_days == 0 with no offset, so on a multiple of the lcm of the cadences, every rule buys.
    • Cost if wrong: if Dca ever gains a phase, this blocker becomes conservative, blocking plans that would actually pass.
  • The rail 3 text says "in DCA buys alone" and that rail 3 counts every BUY.
    • Why: the plan knows only DCA rules, so the figure is a lower bound on that day's spend.
    • Cost if wrong: none.
  • Rail 5 is a warning and names each correlated asset. PAXG is never named.
    • Why: the rail binds only while other correlated exposure is open, which a plan cannot see.
    • Cost if wrong: an advisory note.
  • The rulings are numbered R21/R22.
    • Why: there is no R22 in the plan doc for the brief to have meant. #854's PR takes R20. "One service function per sentence" is implemented as per_day_cap_text and correlated_size_text, following worst_month_text.
    • Cost if wrong: numbering churn in a planning doc.
  • The existing sim parity tests are unchanged apart from a comment.
    • Why: their $350 boundary is decided by the per-order cap on both sides, so parity still holds.
    • Cost if wrong: those two tests assert less than their old comment claimed, and the comment now says so.

Test evidence

  • guards, exact violation sets:
    • a DCA buy over the per-asset limit gives [];
    • the same buy with is_dca=False gives {"per_asset_concentration_cap"}, which also shows DCA holdings still count;
    • a sell gives set() for both classes.
  • Still binding a DCA buy: rail 2 → {"per_order_cap"}, rail 3 → {"per_day_cap"}, rail 5 → {"correlation_adjusted_sizing"}, rail 13 → {"usdc_funding"}, rail 14 → {"monthly_subscription_allowance"}.
  • SimAccount parity: DCA is exempt; a non-DCA buy is still bound.
  • Plan:
    • the busy-day figure for the worked example is $103.47;
    • at the cap exactly, the plan is approvable; one cent over, the blockers tuple equals exactly (per_day_cap_text(...),);
    • existing live rules push the day over the cap;
    • candidate rules are not counted;
    • the rail 5 warnings list exactly BTC then ETH, never PAXG;
    • the CLI renders the blocker line exactly once.
  • TDD: the worker wrote the tests first and reverted the gate to show the failures, e.g. Left contains one more item: 'per_asset_concentration_cap: BTC exposure 1005.0 exceeds ...'. The plan tests failed against the pre-#853 dca_plan.py with AttributeError: ... 'worst_day_cycle_usd'.
  • Mutation checks. For each, I confirmed with cmp that the file changed, then restored it from a saved copy:
    • dropped and not intent.is_dca from guards and from sim: the new tests failed;
    • changed > to >= on the day-cap boundary: the boundary test failed;
    • removed the UNCORRELATED_ASSETS exclusion: the PAXG assertion failed;
    • passed live_rule_count=0: the existing-live test failed;
    • dropped the rail 5 warnings.append: the warning test failed.
  • Full checks on 44c6f1c:
    • uv run pytest -q: 7044 passed, 3 skipped (exit 0)
    • ruff check keel tests: exit 0
    • ruff format --check keel tests: exit 0
    • mypy: exit 0
    • no JS changed.

Conflicts: #854's PR (#867) and #856's PR also edit keel/commands/dca_plan.py.

feat(web): cache the DCA plan's admission screen for 5 minutes (#856) (#870)

Closes #856

GET /api/dca-plan now caches the admission screen (build_screen_report's result) for 5 minutes, keyed on the served deployment's (config_path, db_path) plus the screened product ids and quote currency (#873). The payload carries a presentation-ready screen_age field, and the /rules card shows it as a note, e.g. "Asset screen from 2m ago; refreshes every 5m." (the interval is ScreenCache.TTL_SECONDS, handed through)

Changes

  • keel/web/api.py: a new ScreenCache with a lock and TTL_SECONDS = 300. read_dca_plan asks the cache for the screen, then builds the plan from it.
  • keel/web/server.py: ServeConfig.screen_cache holds one cache per keel serve process, shared by all of ThreadingHTTPServer's threads.
  • keel/commands/dca_plan.py: select_universe and build_dca_plan accept a screen_report_fn, defaulting to build_screen_report. The CLI's behaviour is unchanged.
  • keel/web/payload.py: screen_age is a Field whose value is the age in seconds and whose display is the sentence. The awaiting-budget state (and any call with no screen age) sends a worded not_screened field ("Asset screen: not run yet -- it runs once a budget is entered."), so the card never shows a lone dash.
  • keel/web/static/js/render.js: dcaPlanCard places field(plan.screen_age) in a p.note under the state line.

Rulings

  • The cache holds the screen, not the plan.
    • Why: budget and buffer are per-request query params, and a cached plan would serve stale buy amounts.
    • Cost if wrong: none; plan arithmetic is cheap.
  • The clock is injected as the reader's own now_ts, not as a new callable.
    • Why: test_no_reader_reads_the_clock_a_second_time forbids a second clock read in a reader. now_ts is already the injected clock, so the tests drive the TTL through it.
    • Cost if wrong: a reviewer who wanted a Callable[[], int] would need a small signature change.
  • The key is (config_path, db_path), even though the cache already lives on the per-process ServeConfig.
    • Why: the cache stays correct even if one instance ever serves two deployments.
    • Cost if wrong: none.
  • An entry is fresh while its age is under 300 s; at exactly 300 s it is rebuilt.
    • Why: this matches guards.py's due <= now convention.
    • Cost if wrong: a one-second difference.
  • The lock is held while a miss rebuilds.
    • Why: this prevents a thundering herd when concurrent polls land on a stale entry.
    • Cost if wrong: one poll waits for a few local DB reads.
  • The age is clamped at 0. I added this after the worker finished.
    • Why: a request stamped before a later-stamped request built the entry (it waited on the lock) showed "-1s ago".
    • Cost if wrong: none.
  • No service-worker cache bump.
    • Why: sw.js?v=<build> is keyed on the commit, so a new commit already invalidates old clients.
    • Cost if wrong: a stale shell until the next build.

Test evidence

  • Each test counts calls to build_screen_report, which also shows the fixture reaches read_dca_plan.
    • A hit within the TTL keeps the count at 1 and reports age 299.
    • Exactly 300 s later the count is 2, and 600 s later it is 2.
    • A different config path at the same instant makes the count 2.
  • ScreenCache is also unit-tested directly:
    • the 0/299/300 s boundary;
    • four keys (two config paths × two db paths);
    • 8 threads racing a cold cache build exactly once;
    • a request stamped before the build reads age 0. This test failed first with -1 == 0, and passes after the clamp.
  • The payload's screen_age is asserted as an exact field dict, and the awaiting state as absent.
  • The client test asserts the pairing: one el("p","note") that appends field(plan.screen_age) and is appended to the card.
  • TDD: the worker's tests failed first with AttributeError: ... 'screen_cache' / no attribute 'ScreenCache', and the card test failed with "sends screen_age; the card never places it".
  • Mutation checks. For each, cmp confirmed the file changed, and the file was restored from a saved copy:
    • < to <= on the TTL: the two boundary tests failed;
    • key (db_path, db_path): the key-isolation test failed.
  • Real browser (Playwright): keel serve ran on a copy of keel/templates/config.yaml with a scratch DB on free port 57711.
    • I unregistered the service worker and cleared its caches first.
    • /rules?budget=500&buffer=0.1 showed "Asset screen from 0s ago; refreshes every 5m." with 0 console errors and 0 warnings; /rules with no budget shows the not-run-yet line (re-verified at dd5fa82 after unregistering the service worker).
    • A reload 53 s later showed "from 53s ago", which shows the cache survived in the running server.
    • The server was then killed.
  • Full checks on a34c73f:
    • uv run pytest -q: 7038 passed, 3 skipped (exit 0)
    • ruff check keel tests: exit 0
    • ruff format --check keel tests: exit 0
    • mypy: exit 0
    • node --check keel/web/static/js/render.js: exit 0

Conflicts: this PR, #853's PR and #854's PR (#867) all touch keel/commands/dca_plan.py, but in different functions.

fix(dca): the passing-value suggestion clears rails 3 and 2 as well as 14 (#874) (#876)

Closes #874

What changed

  • _within_budget_rails (new, private) is the passing predicate for both binary searches. A candidate --budget / --buffer-pct now has to clear three rails:
    • rail 14: the worst month within the cap, minus the R20 reservation for live DCA commitments (kept, per the orchestrator ruling);
    • rail 3: worst_day_cycle + existing_live_daily <= max_per_day_usd, the same busiest-day sum #869 uses;
    • rail 2: every per_buy <= max_per_order_usd.
  • All three are monotone in budget and buffer, so both searches stay exact, and the boundary asserts are unchanged.
  • The _passing_suggestion_text clause is now appended to every budget-driven blocker: rail 14, rail 3 and each rail 2. The CLI and the /rules card both show plan.blockers verbatim, so they share the one text function.
  • When no value passes, the clause says so ("no positive --budget would pass ...").
  • On an unlimited tier nothing is reserved against a monthly cap.
  • Ruling R23 is added to docs/superpowers/plans/2026-09-27-dca-plan.md. No public names change.

Evidence

Red first. All five new tests failed on main. The pre-fix suggestion, re-run through build_dca_plan, is still blocked:

  • max_per_day_usd=60: 483.26 / 0.1302 → 1 blocker (rail 3).
  • max_per_order_usd=30: 483.26 / 0.1302 → 1 blocker (rail 2, BTC $40.00).

New tests. Each asserts the exact blockers tuple, never substrings.

  • test_the_issue_874_suggestion_clears_rail_3_as_well_as_rail_14 and test_the_suggestion_clears_rail_2_as_well_as_rail_14:
    • re-running at the suggested budget and at the suggested buffer gives blockers == ();
    • one cent above the budget, or 0.0001 below the buffer, is blocked by exactly the one rail-3 (or BTC rail-2) blocker.
  • test_a_rail_3_blocker_on_its_own_now_names_a_passing_value and test_a_rail_2_blocker_on_its_own_now_names_a_passing_value.
  • test_rail_3_says_no_value_passes_when_live_rules_alone_fill_the_day (unlimited tier).
  • Three existing rail-3 tests now expect the appended clause.

Mutation check. Each mutant was applied to the source (a 2-line diff, verified), and each was killed:

  • rail-3 term removed: 7 tests fail, including all three new rail-3 tests;
  • rail-2 term (all(per_buy <= max_per_order_usd ...)) replaced by return True: both new rail-2 tests fail.

Local checks (exit codes): pytest=0 (7074 passed, 3 skipped), ruff check=0, ruff format --check=0, mypy=0.

Live is not affected: per-order is $200, per-day is $200 and the worst day is $150.

chore(release): 0.20.0 -- DCA exempt from the per-asset rail; dca plan and its read-only card (#877)

Version bump only: all 8 pyprojects, their == pins, and uv.lock go from 0.19.0 to 0.20.0. That's 9 files, the same shape as #845. uv lock --check passes, and there's no schema change since 0.19.0.

MINOR, because rail 6 changes scope for DCA (#869), and a new command and route land (#846, #850).

  • #846: keel dca plan
  • #850: GET /api/dca-plan and the read-only card on /rules
  • #869 (#853): DCA buys are exempt from rail 6; the plan blocks on rail 3 and warns on rail 5
  • #867 (#854), #876 (#874): a blocked plan names the values that pass rails 14, 3 and 2
  • #870 (#856): the admission screen is cached for 5 minutes
  • #858 (#855): the live-rules manifest is synced
  • #859: the sell-side design spec

Local: pytest 7074 passed, 3 skipped; ruff and mypy clean.