Repository navigation
keel v0.20.0
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.
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∩ positivetarget_weights, minus assets that already have a non-disableddcarule. Weights are renormalised over that set. - Every excluded asset is listed with each reason that applies:
not_on_allowlist,not_admitted,no_weight, orhas_dca_rule. An asset with a DCA rule is shown as "existing, unchanged".
- The universe is allowlist ∩ admitted by
- Spend is budget × (1 − buffer). It is checked against rail 14's monthly buy cap for the deployment's venue, read exactly as
guards.checkreads it. A parity test drivesguards.checkat 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_pctand 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 onecandidatedcarule per asset throughrules.add_rule_row, all or nothing (R2).- The command never promotes, never writes
paperorlive, 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.pyis a pure service with no click, ready for PR 2's GET-only reader.keel/commands/dca.pyis the thin click front-end.
Rulings in force
R1–R19 as in the plan, with these changes:
- R7 amended (after #843). Each existing
liveDCA rule's monthly commitment uses that rule's ownbudget_usd, because the executor sizes each DCA buy from the rule'ssize_usd. The commitment is added to spend, and the plan warns, without blocking, when the sum exceeds the cap. A live rule withdip_bonus_pct > 0gets 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 noexecutor_budget_usdfield. A test pins the warning's absence. - #842 note. Nothing in the output describes
max_exposure_usdas 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.pyandtests/commands/test_dca_cli.py.tests/test_rail14_is_a_buy_cap.pygets one test, andSERVICE_MODULESgets 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.
- a write inside
- Fixtures are proven to reach the code under test: the fake
screen_fnis called 3 times, the patchedbuild_rule_from_paramsis called for PAXG, the patchedadd_rule_rowis called twice, and_open_repo_rois called once off a TTY. uv run pytest -q: 6962 passed, 3 skipped, exit 0.uv run ruff check keel testsexited 0,uv run ruff format --check keel testsexited 0, and bareuv run mypyexited 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 askeel 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 anawaiting_budgetpayload 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_checkis the worst UTC calendar month rail 14 was compared against, with the checked total as itsvalue.- It is judged
badover the cap,goodwithin it, andneutralwhen 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
/rulessits 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/--dbfor the served deployment. - It has no button, anchor, form or input, and no event handler.
- 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
- 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
DcaPlanErroris a 400ApiRefusal. This includes the onesbuild_dca_planraises over the config (target_weightskeys colliding by case, a non-finite weight).- Why:
ApiRefusalis 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.
- Why:
- R22: the worst-month sentence is written once, by a new service function
dca_plan.worst_month_text(plan). The CLI'schecked against the cap:line and the payload'scap_check.displayboth 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
erroras well as itsdata(rulesView(data, sort, onSort, plan, planError)).- Why: with
dataalone, a 400 would show a bare "could not be read" and throw away the reason, such as the fraction hint forbuffer=10. - Cost if wrong: one extra parameter.
- Why: with
- 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 everyAPI_ROUTESpath sits underAPI_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_fornow takesmonkeypatch.
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 --checkonrender.jsandmain.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
codeelement, paired withplan.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
ROUTESrow, and themountcall passing both the reading'sdataand itserror. - The page-address seeding: one
location.searchread, namesbudget/bufferonly, and thedca-planbag only.
- The card's tags are on a closed list, with a scan that sees a mutated-in
- Mutations applied and killed, each confirmed to have changed the source before the run:
- A module-level
screen_productimport fails the route tests (the patch no longer reaches the reader). - Removing the
DcaPlanErrorcatch fails the config-refusal test. - Renaming row keys (
row.fee,item.reasons,rule.per_buy) fails the row-key scan. - Dropping the
cap_checkfigure, dropping a table cell, droppingplanReading.error, widening the seeded names, and not placingerror.detaileach fail their test.
- A module-level
- Browser check (Playwright): a real
keel serveon a copy ofkeel/templates/config.yamlwith a temp DB, on a free port. The service worker was unregistered and its caches deleted first./rules?budget=500&buffer=0.1shows 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./ruleswith 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
codeelement. The rule ledger stays in place. - At 390px there is no horizontal scroll.
buffer=10shows 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. rulesViewtakes a fifth parameter,planError(R23).- The card's
excludedandexistinglists are built with.mapover 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-commandCSS 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 toDca'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 initreseed 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.
- every DCA rule is
- Refreshed one sentence in
scripts/rule_manifest.py's module docstring. It said the live DCA rule is50on 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 0ruff format --check keel tests scripts: exit 0mypy: 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_usdto"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.
- set BTC
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.REDUCEandRule.reduce_signal. TodayRulecan only express "buy" or "sell all",position_rule:<product>has one owner (dca, whose exit is hardcodedFalse), 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
positionsledger (FIFO, entry fees in the basis), never fromorders; 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_usdis 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 typedyesat a TTY per proposal, orexecution: autoplus a newkeel autonomy on --sellswindow. The live autonomy flag does not release the new sell class. No web or MCP verb. - Audit: a
sell_proposalstable for every evaluated reduction, placed or not, chained intoaudit_events;trade_outcomes.is_dcaderived from the tranches so rail 16 never counts a sleeve sale. - Promotion: a
sleeve_sellclass 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
--budgetthat passes at the given buffer; - the smallest
--buffer-pctthat 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_usdis factored out ofbuild_dca_plan's loop._worst_month_spend_foris 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_usdandDcaPlan.min_passing_buffer_pcthold the values (orNone). The text is built from those fields, and they are formatted withformat(x, 'f'), so they never print with an exponent.- New ruling R20 is in
docs/superpowers/plans/2026-09-27-dca-plan.mdand inbuild_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.pyis unchanged.- Why: the card already passes
plan.blockersthrough 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.
- Why: the card already passes
Test evidence
- The worked example pins 483.26 and 0.1302.
- The re-run proof calls
build_dca_planat each suggested value and assertsapprovable. 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
cmpthat 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 andcmpconfirmed it was identical. - Full checks after the final commit:
uv run pytest -q: 7033 passed, 3 skipped (exit 0)ruff check keel tests: exit 0ruff format --check keel tests: exit 0mypy: 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 foris_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_openhas the same exemption, sokeel simulatemodels 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 againstcaps.max_per_day_usd.per_day_cap_textbuilds the sentence. - Rail 5 (correlated) warning, R22. A per-buy above
max_per_order_usd x CORRELATED_SIZE_SCALE, for an asset outsideUNCORRELATED_ASSETS, raises a warning. Both constants are imported fromguards.py.correlated_size_textbuilds the sentence. - The CLI (
render_dca_plan) and the /rules card (dca_plan_payload) both showplan.blockersandplan.warningsunchanged.
- Rail 3 (per day) blocker, R21. The busiest day is the planned per-buys plus every existing live DCA rule's own
- 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 inkeel/templates/config.yamlandkeel/templates/config.live.yaml; - re-synced root
config.yaml;cmpshows it is byte-identical to the template.
- new
Rulings
- Rail 6 uses rail 4's gate exactly.
- The rail 3 busiest day is the plain sum of all planned and live per-buys, with no phase search.
- Why:
Dca.detectisepoch_day % cadence_days == 0with no offset, so on a multiple of the lcm of the cadences, every rule buys. - Cost if wrong: if
Dcaever gains a phase, this blocker becomes conservative, blocking plans that would actually pass.
- Why:
- 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_textandcorrelated_size_text, followingworst_month_text. - Cost if wrong: numbering churn in a planning doc.
- 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
- 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=Falsegives{"per_asset_concentration_cap"}, which also shows DCA holdings still count; - a sell gives
set()for both classes.
- a DCA buy over the per-asset limit gives
- 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
blockerstuple 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-#853dca_plan.pywithAttributeError: ... 'worst_day_cycle_usd'. - Mutation checks. For each, I confirmed with
cmpthat the file changed, then restored it from a saved copy:- dropped
and not intent.is_dcafrom guards and from sim: the new tests failed; - changed
>to>=on the day-cap boundary: the boundary test failed; - removed the
UNCORRELATED_ASSETSexclusion: the PAXG assertion failed; - passed
live_rule_count=0: the existing-live test failed; - dropped the rail 5
warnings.append: the warning test failed.
- dropped
- Full checks on 44c6f1c:
uv run pytest -q: 7044 passed, 3 skipped (exit 0)ruff check keel tests: exit 0ruff format --check keel tests: exit 0mypy: 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 newScreenCachewith a lock andTTL_SECONDS = 300.read_dca_planasks the cache for the screen, then builds the plan from it.keel/web/server.py:ServeConfig.screen_cacheholds one cache perkeel serveprocess, shared by all ofThreadingHTTPServer's threads.keel/commands/dca_plan.py:select_universeandbuild_dca_planaccept ascreen_report_fn, defaulting tobuild_screen_report. The CLI's behaviour is unchanged.keel/web/payload.py:screen_ageis aFieldwhose 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 wordednot_screenedfield ("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:dcaPlanCardplacesfield(plan.screen_age)in ap.noteunder 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_timeforbids a second clock read in a reader.now_tsis 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.
- Why:
- The key is
(config_path, db_path), even though the cache already lives on the per-processServeConfig.- 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'sdue <= nowconvention. - Cost if wrong: a one-second difference.
- Why: this matches
- 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.
- Why:
Test evidence
- Each test counts calls to
build_screen_report, which also shows the fixture reachesread_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.
ScreenCacheis 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_ageis asserted as an exact field dict, and the awaiting state asabsent. - The client test asserts the pairing: one
el("p","note")that appendsfield(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,
cmpconfirmed 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 serveran on a copy ofkeel/templates/config.yamlwith a scratch DB on free port 57711.- I unregistered the service worker and cleared its caches first.
/rules?budget=500&buffer=0.1showed "Asset screen from 0s ago; refreshes every 5m." with 0 console errors and 0 warnings;/ruleswith 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 0ruff format --check keel tests: exit 0mypy: exit 0node --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-pctnow 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_textclause is now appended to every budget-driven blocker: rail 14, rail 3 and each rail 2. The CLI and the/rulescard both showplan.blockersverbatim, 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_14andtest_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.
- re-running at the suggested budget and at the suggested buffer gives
test_a_rail_3_blocker_on_its_own_now_names_a_passing_valueandtest_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 byreturn 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-planand 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.