diff --git a/docs/superpowers/plans/2026-09-27-dca-plan.md b/docs/superpowers/plans/2026-09-27-dca-plan.md new file mode 100644 index 00000000..de7f2fad --- /dev/null +++ b/docs/superpowers/plans/2026-09-27-dca-plan.md @@ -0,0 +1,2753 @@ +# DCA Plan (service, CLI, read-only web card) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** `keel dca plan --budget --buffer-pct ` proposes a multi-asset weekly DCA schedule from the admitted, weighted allowlist. At a TTY it can write one `candidate` `dca` rule per asset. The web console's `/rules` view shows the same proposal read-only, with the exact CLI command to apply it. + +**Architecture:** A pure service module, `keel/commands/dca_plan.py`, has no click in it. It returns a frozen, typed `DcaPlan`, renders it to lines, and applies it through `keel.commands.rules.add_rule_row`, which is the body of `keel rules add`. A thin click group, `keel/commands/dca.py`, parses options, runs the `[Y]/[E]/[N]` loop, and calls the service. In PR 2, a GET-only reader in `keel/web/api.py` calls the same service. A new `payload.dca_plan_payload` serialises the plan, and `render.js` places it as a card with no controls. + +**Tech Stack:** Python 3.14, click, `Decimal`, pytest with `CliRunner`, uv, ruff, mypy. On the client: vanilla ES modules, `// @ts-check`, and no JS test harness. Client tests are Python scans over the JS source. + +**Spec:** the feature brief in the task hand-off of 2026-09-27, restated in "Global Constraints" below. The user's decisions are final. Related records: +- `docs/rails/rail-14-subscription-allowance.md`: rail 14 is a buy cap, not a fee waiver (#836). +- `keel/web/payload.py`: module docstring, Rules 1 to 3. +- `keel/capabilities.py`: the browser performs no capability increase. + +## Global Constraints + +- The command is `keel dca plan --budget --buffer-pct `. `--cadence-days` is optional and defaults to `7`. +- The universe is `config.allowlist` ∩ assets the admission screen currently admits (`build_screen_report(..., screen_product)`) ∩ assets with a positive `target_weights` entry, minus assets that already have a non-disabled `dca` rule. Weights are renormalised over that set. +- Every excluded asset is reported with its reason(s): not admitted, no weight, or already has a DCA rule. "Not on allowlist" is added by Ruling R4. +- Spend is `budget × (1 − buffer_pct)`. It must not exceed rail 14's monthly buy cap for the deployment's venue. The cap is read the way `keel/execution/guards.py` rail 14 reads it: `repo.get_broker_subscription(venue)`, then `record.allowance_usd(now_ts, config.subscription.unsubscribed_allowance_usd)`, with the unsubscribed allowance when there is no record. +- Rail 14 is a **monthly BUY cap, not a fee waiver** (#836). Output says so, and the text "fee-free" appears nowhere in the output. +- Each asset gets a `Dca` rule (`keel/strategy/rules/dca.py`) with `cadence_days=7` by default. The per-buy `budget_usd` is `monthly share × cadence_days / (365.25/12)`. +- Output shows, per asset, the cadence, the per-buy amount and the monthly total. It also shows the grand total and the estimated fees at `config.fees.taker_pct`, **labelled as the configured rate**. +- Each per-buy amount is checked against the venue minimum order size where keel knows it. keel does not know it: `keel_broker_api/results.py::Instrument` carries no minimum, "Minimum sizes are still absent for the original reason -- nothing reads them". The output says so. +- At a TTY the prompt is `[Y] Approve / [E] Edit weights / [N] Cancel`. `[E]` edits per-asset weights, renormalises and re-shows the plan. `[Y]` writes one `dca` rule per asset **at status `candidate` only**, through `add_rule_row`, which wraps `agent.build_rule_from_params` and `Repository.insert_rule`. +- The command never promotes, never touches `paper` or `live`, and never modifies an existing rule. Off a TTY it prints the plan and writes nothing. +- An asset that already has a non-disabled `dca` rule is listed as "existing, unchanged" and gets no new rule. +- Service modules may not import `keel.cli` (`tests/commands/test_service_isolation.py`). +- PR 2's route is **GET-only**. There is no write verb, no apply button and no form. Money crosses the wire as strings, values arrive presentation-ready, and state is an explicit field (`keel/web/payload.py` Rules 1 to 3). +- The service worker never caches `/api/`. +- Client tests assert structure, not substrings. +- TDD applies to every task: the failing test comes first. +- The suite is `uv run pytest -q`, `uv run ruff check keel tests`, `uv run ruff format --check keel tests` and bare `uv run mypy`, never `mypy keel/`. This plan touches nothing under `packages/`, so no `PYTHONPATH` override is needed. +- Work happens in a git worktree. The user commits in the main checkout concurrently. + +## Rulings + +- Ruling R1: the write path is `keel.commands.rules.add_rule_row(...)`, called once per asset with `params_json={"cadence_days": N, "budget_usd": ""}`, rather than a hand-rolled `build_rule_from_params` plus `insert_rule`. + - Why: `add_rule_row` *is* `keel rules add`'s body (the #390 C4 service split). It already wraps `build_rule_from_params` and `insert_rule`, and adds rails 18/19 product parsing, type checks and non-finite checks. A second copy is the drift that `rules.py`'s docstring warns about. + - Cost if wrong: none functional. `add_rule_row` echoes its own `added rule N ... next: keel rules backtest N` lines, and those appear in the CLI output. +- Ruling R2: approval is all-or-nothing. `apply_dca_plan` first pre-validates every buy: `parse_products_option` plus `agent.build_rule_from_params("dca", params)`. It then re-reads the rules table and refuses the whole plan if any planned asset has gained a non-disabled `dca` rule since the preview. Only after both checks does it write. + - Why: `add_rule_row` commits per row, so a mid-plan refusal would leave a partial schedule. Pre-validation makes that unreachable for every refusal `add_rule_row` can make on these params. + - Cost if wrong: if a row still fails after pre-validation (for example a locked database), earlier rows exist. The refusal names the ids already written. +- Ruling R3: the cap is read by a service helper, `monthly_buy_cap()`, that repeats rail 14's three reads, as `keel/commands/status.py:268` and `keel/commands/subscription.py:168` already do. `guards.py` is not refactored. + - Why: rail 14 is safety-critical. #836 changed only its wording. A parity test drives `guards.check` itself at the cap and one cent over it, so the copy cannot drift silently. + - Cost if wrong: a guard refactor later has to keep the parity test green. That is the intended cost. +- Ruling R4: assets named in `target_weights` but absent from the allowlist are listed as excluded with the reason `not_on_allowlist`. The spec names three reasons. + - Why: otherwise a weight vanishes with no line saying why, which is the silent-drop class the repo keeps paying for (#198). + - Cost if wrong: one extra output line that the user may consider noise. +- Ruling R5: spend is the budget minus the buffer, quantised **down** to cents. Each per-buy amount is quantised **down** to cents, and each monthly total is recomputed from the rounded per-buy and quantised down. The fee estimate is quantised **up**. + - Why: every rounding step errs toward spending less and estimating more fees, so the displayed total never exceeds the spend that was checked against the cap. + - Cost if wrong: up to $0.01 per asset per buy is left unallocated. +- Ruling R6: the cap check compares **spend** (budget × (1 − buffer)) against the **full-month** allowance. If the allowance is `None` (unlimited), there is no cap check. If spend exceeds the cap, that is a **blocker**: `[Y]` is not offered and nothing can be written. + - Why: this is the spec's "must not exceed", and the larger of spend and planned total is spend (R5). + - Cost if wrong: under `pacing: even_daily`, an early-month buy can still hit the paced cap. The output states the pacing mode when it is `even_daily`. + - **Amended 2026-09-27, #847:** comparing "spend" (the 30.4375-day average-month figure) against the cap was wrong on its own terms, not just under `even_daily` pacing. Rail 14 caps the UTC **calendar** month (`guards._monthly_buy_spend_usd`, `_utc_month_bounds`), and every rule in one plan shares a cadence and buys on the same days (`epoch_day % cadence_days == 0`, `Dca.detect`) -- so a calendar month can hold MORE buy days than the average implies. For the default 7-day cadence, the worst calendar month (a 31-day month phased so day 1 is a hit) holds 5 buy days, not ~4.35. The worked example ($500 budget, 10% buffer -> $450 spend, weights .4/.3/.3, $500 cap) looked approvable under the old check ($450 < $500) but its per-cycle total is $103.47/week; 5 × $103.47 = $517.35 in a month like October 2026, which rail 14's real veto (calendar-month, not average-month) would have blocked mid-month. The blocker check (and the R7 warning's trigger, for consistency) now compares `_worst_month_buy_days(cadence) × the per-cycle total` against the cap instead. Per-buy SIZING is unchanged -- only the blocker's comparison moved. `DcaPlan` carries the figures used (`worst_month_buy_days`, `worst_month_cycle_usd`, `worst_month_spend_usd`), and the renderer shows them next to the cap line, reusing them verbatim rather than recomputing a total that could exceed what was actually checked. +- Ruling R7 (amended 2026-09-28, after #843): the live monthly commitment of existing `live` DCA rules is added to spend, and the plan **warns**, without blocking, when the sum exceeds the cap. Each live row's commitment is computed from **that rule's own `budget_usd`** (× its `dip_bonus_pct` ceiling is not modelled; a row with `dip_bonus_pct > 0` adds a warning that its buys can exceed the listed amount), because since #843 the live executor sizes each DCA buy from the rule's `setup.context["size_usd"]` (`keel/execution/executor.py::_dca_budget`), falling back to `config.dca.budget_usd` only when a setup carries no `size_usd`. Only `live` rows count, because rail 14 sums only `mode="live"` orders. + - Why: rail 14 caps every live BUY, including non-DCA rules the plan cannot predict. A warning states the collision without refusing a schedule that is legal on its own. + - Cost if wrong: an over- or under-stated warning; the dip-bonus warning covers the only way a row spends more than its listed amount. + - **Amended 2026-09-27 (review of #846):** a stored row with no `budget_usd` is counted at `Dca.__init__`'s own default (read from its signature in `dca_plan.py`, the rule class untouched), which is what `agent.build_rule_from_params` builds it with, not $0; and each such live row gets a named warning, because the figure is inferred rather than stored. The warning's trigger uses the same worst-calendar-month arithmetic as R6 (amended, #847). +- Ruling R8: a per-buy amount above `config.caps.max_per_order_usd` is a **blocker**, and so is a per-buy amount that rounds to $0.00. + - Why: the first is vetoed by a rail on every buy, so the rule would never trade. For the second, `Dca.__init__` raises `budget_usd must be positive`. Neither is a schedule. + - Cost if wrong: the user must edit weights or raise the budget. That is the correct action anyway. +- Ruling R9 (withdrawn 2026-09-28): the planned warning that "the live executor sizes every DCA buy from `config.dca.budget_usd`" is **not implemented** -- #843 made the executor honour the rule's own amount, so the statement is false. Task 3 must NOT emit it, and a test pins its absence (no warning mentions `dca.budget_usd`). Wherever Task 3, Task 6 or the Self-Review below still refers to R9's warning or to `executor.py:736-746`, this amendment overrides it. + - Why: a warning that states something false about the money path is worse than none. + - Cost if wrong: none. +- Note (2026-09-28, after #842): rail 4 (`max_exposure_usd`) no longer vetoes DCA buys; rails 14 and 6 still bind them. The plan's output must not describe `max_exposure_usd` as a limit on DCA. +- Ruling R10: `--buffer-pct` is **required**, not defaulted. + - Why: the spec's signature names it. A silent default of 0 turns "budget" into "spend" without the operator saying so. + - Cost if wrong: one extra flag to type. +- Ruling R11: `--buffer-pct` is a fraction in `[0, 1)`. `10` is refused with a hint that `0.1` means 10%. + - Why: config fractions in this repo (`max_per_asset_pct`, `taker_pct`) are fractions. Reading `10` as 10% would be a second convention. + - Cost if wrong: one refused invocation, with the fix in the message. +- Ruling R12: off a TTY, the command prints the plan, writes nothing and exits **0** when the plan is approvable, or **1** when it has blockers. + - Why: a script can see "this plan cannot be applied" without parsing text. + - Cost if wrong: a CI caller that expects 0 regardless. +- Ruling R13: the service module (`keel/commands/dca_plan.py`) imports no click. The click group lives in `keel/commands/dca.py`. + - Why: the brief says "pure service module". The web layer imports it, and a test pins the absence of click. + - Cost if wrong: one extra file. +- Ruling R14: `[E]` edits only **editable** assets: allowlisted, admitted, and with no existing DCA rule. It starts from each asset's configured `target_weights` value, or from the last session value. A weight of 0 excludes the asset with the detail "set to 0 in this session". Nothing edited is written to `config.yaml`. + - Why: an edit must not re-admit an asset the screen rejected, and must not create a second DCA rule beside an existing one. + - Cost if wrong: an asset with no configured weight cannot be added from `[E]`. The operator adds a `target_weights` entry instead. +- Ruling R15: the web reader answers a request without `budget` with an explicit `awaiting_budget` payload of the same shape (200). A malformed `budget` or `buffer`, or a `budget` given without a `buffer`, gets a **400** `ApiRefusal`. + - Why: `test_every_route_answers_json_with_the_envelope` requests every route bare and requires 200 with non-null `data`. Refusing a bad input rather than guessing at it is `_sort_request`'s precedent. + - Cost if wrong: none. +- Ruling R16: the card gets its inputs from the page's own address, `/rules?budget=500&buffer=0.1`. `main.js` reads them once into the `dca-plan` endpoint's query bag. There is no form, input or button. + - Why: the brief forbids a form. The URL is the only non-interactive input, and the payload's `awaiting_budget` state tells the reader to add it. + - Cost if wrong: a reader has to edit the URL. That is deliberate friction on a read-only card. +- Ruling R17: the card's command includes `--config --db ` (shell-quoted) for the served deployment, before the `dca plan` subcommand. + - Why: `keel serve` may serve a non-default profile, and a command without them would plan against a different deployment. That would not be "the exact CLI command". + - Cost if wrong: a longer command line. +- Ruling R18: PR 2 is a stacked branch, `feat/dca-plan-web`, cut from PR 1's branch `feat/dca-plan-service`. Its PR targets `main` after PR 1 merges, or `feat/dca-plan-service` until then. + - Why: PR 2 imports PR 1's service. The user merges PRs, and the implementer opens them with `gh pr create` (memory: "Open the PR, let them merge"). + - Cost if wrong: one retarget. +- Ruling R19: no capability row is added to `keel/capabilities.py`. + - Why: writing `candidate` rules is not a capability increase. `keel rules add` writes the same rows ungated and has no row there. A `candidate` rule cannot place an order until `rules promote` clears it. + - Cost if wrong: `tests/test_capabilities.py` scans only `_require_interactive_confirmation` call sites, and this feature adds none, so no test would object. The only cost would be an inventory row the user wanted. + +## Review Focus + +1. **The per-buy amount rounds to $0.00** for a small budget or weight, for example `--budget 1` with a 1% weight. A person expects a named refusal ("BTC's per-buy rounds to $0.00"), not a traceback from `Dca.__init__` and not a silently dropped asset. Pinned in Task 3. +2. **An unattested, suspect, lapsed or overdue subscription.** The cap is the unsubscribed default (0 in every shipped config). A person expects a blocker that names the reason and `keel subscription attest --venue `, not "$450 exceeds $0". Pinned in Tasks 1 and 3. +3. **A DCA rule appears between preview and `[Y]`**, for example written from the user's concurrent session. A person expects the whole approval refused and zero rows written, not a second DCA rule beside the new one. Pinned in Task 5. +4. **Percent-shaped or exotic numeric input**: `--buffer-pct 10`, `--budget NaN`, `--budget 1e3`. A person expects `10` refused with the 0.1 hint and `NaN` refused. They also expect `1e3` accepted and echoed as `1000` in any command the web prints, never as `1E+3`. Pinned in Task 1 and Task 7. +5. **A `target_weights` key that is not on the allowlist, or has different casing** (`btc: 0.4`). A person expects the weight either to apply to `BTC` or to be listed as excluded with a reason, never silently lost. Pinned in Task 2. + +--- + +## File Structure + +**PR 1: service and CLI (branch `feat/dca-plan-service`)** +- Create `keel/commands/dca_plan.py`: the pure service. + - Types: `PlanInputs`, `BuyCap`, `PlannedBuy`, `ExcludedAsset`, `ExistingDcaRule`, `DcaPlan`, `DcaPlanError`, `DcaPlanRefused`. + - Functions: `parse_plan_inputs`, `parse_weight`, `monthly_buy_cap`, `build_dca_plan`, `render_dca_plan`, `apply_dca_plan`, `apply_command`. + - No click. +- Create `keel/commands/dca.py`: the `dca` click group with the `plan` command. It is the TTY loop and nothing else. +- Modify `keel/cli.py`: import `dca_group` and register it next to the `rules_group` registration (`cli.add_command(rules_group)`, line ~1212). +- Modify `tests/commands/test_service_isolation.py`: add `keel.commands.dca_plan` and `keel.commands.dca` to `SERVICE_MODULES`. +- Create `tests/commands/test_dca_plan.py`: the service tests. +- Create `tests/commands/test_dca_cli.py`: the CLI tests. +- Modify `tests/test_rail14_is_a_buy_cap.py`: add one test pinning the plan's rail 14 wording. + +**PR 2: API and card (branch `feat/dca-plan-web`, stacked)** +- Modify `keel/web/payload.py`: `dca_plan_payload(plan, *, command)` and `dca_plan_awaiting_payload(*, command)`. +- Modify `keel/web/api.py`: `read_dca_plan`, plus an `API_ROUTES["/api/dca-plan"]` entry with `html_route="/rules"`. +- Modify `keel/web/static/js/render.js`: `export function dcaPlanCard(plan)`, and `rulesView(data, sort, onSort, plan)` appends it. +- Modify `keel/web/static/js/main.js`: the rules route's `endpoints: ["rules", "dca-plan"]`, the page-query seeding and passing `readings[1]`. +- Modify `tests/web/test_api.py`: add `/api/dca-plan` to the `API_ROUTES` tuple and add the route tests. +- Modify `tests/web/test_payload.py`: the payload tests. +- Modify `tests/web/test_client_assets.py`: add a `_VIEW_ENDPOINTS` row and the card's structural tests. +- Modify `tests/web/test_pwa.py`: a test that every `API_ROUTES` path sits under the worker's `API_PREFIX`. + +--- + +## Setup (once, before Task 1) + +The worktree already exists at `../keel-wt-dca` on `feat/dca-plan-service`, cut from `origin/main` at `ed9cb2a`. Always `cd` into it in the same command as the git call: `cd /Users/elmehdiaitbrahim/Development/work/CodeGate/keel-wt-dca && git ...`. Never let a failed `cd` fall through to git. + +- [ ] Run `cd /Users/elmehdiaitbrahim/Development/work/CodeGate/keel-wt-dca && uv sync && uv run pytest -q tests/commands/test_rules_add.py tests/commands/test_service_isolation.py`. + + Expected: PASS. This is the baseline. + +Read these before touching the files named. Each one's docstring states rules the new code must keep: +- `keel/commands/rules.py`: the module docstring, `add_rule_row`, `RulesOutcome`, `_line_sink`, `_json_plain`. +- `keel/commands/admission.py`: `build_screen_report`, `ScreenFn`. +- `keel/commands/assets.py`: `screen_product`. +- `keel/commands/_common.py`: the module docstring on monkeypatch targets, `_is_interactive`, `_bound_venue_or_default`. +- `keel/commands/journal.py::journal_add`: the `_is_interactive` precedent for a TTY-only write that is not dangerous. +- `keel/execution/guards.py`, lines 695-790: rail 14. +- `keel/execution/executor.py`, lines 729-746: the DCA sizing note. + +--- + +# PR 1: service and CLI + +### Task 1: Inputs, the rail 14 cap reader, and the apply command string + +**Files:** +- Create: `keel/commands/dca_plan.py` +- Test: `tests/commands/test_dca_plan.py` + +**Interfaces:** +- Consumes: `Repository.get_broker_subscription(venue) -> BrokerSubscription | None`, `keel_core.subscription.SubscriptionStatus`, `Config.subscription.{unsubscribed_allowance_usd,pacing}`. +- Produces: + - `class DcaPlanError(ValueError)`: an operator input error. Its message is shown as-is. + - `@dataclass(frozen=True) class PlanInputs(budget_usd: Decimal, buffer_pct: Decimal, cadence_days: int = 7)`. + - `parse_plan_inputs(budget: str, buffer_pct: str, cadence_days: int = 7) -> PlanInputs`. + - `parse_weight(raw: str, asset: str) -> Decimal`: a finite value `>= 0`. + - `@dataclass(frozen=True) class BuyCap(venue: str, allowance_usd: Decimal | None, degraded_reason: str, pacing: str)`, with the property `in_force: bool` (`degraded_reason == ""`). + - `monthly_buy_cap(repo, config, *, venue: str, now_ts: int) -> BuyCap`. + - `apply_command(inputs: PlanInputs | None, *, config_path: str | None = None, db_path: str | None = None) -> str`. + - Constants: `MONTH_DAYS = Decimal("365.25") / Decimal("12")`, `DEFAULT_CADENCE_DAYS = 7`. + +- [ ] **Step 1: Write the failing tests** + +```python +"""`keel.commands.dca_plan` -- the DCA plan SERVICE (no click). + +Pins, in order: input parsing (percent-shaped and non-finite input refused), rail 14's cap read +exactly as `guards.check` reads it (parity driven through `guards.check` itself), the apply +command's no-exponent spelling, then (Tasks 2-5) the universe, the amounts, the rendering and +the all-or-nothing write. +""" + +from __future__ import annotations + +import ast +from decimal import Decimal +from pathlib import Path + +import pytest +from keel_core.subscription import SubscriptionStatus + +from keel.commands import dca_plan as dca_mod +from keel.commands.dca_plan import ( + DcaPlanError, + PlanInputs, + apply_command, + monthly_buy_cap, + parse_plan_inputs, + parse_weight, +) +from keel.execution import guards +from tests.conftest import attest_subscription +from tests.execution.test_guards import NOW_TS, _intent, _keys, _roomy_config, _unattested_repo + + +def test_the_service_module_imports_no_click() -> None: + """R13: a pure service. The web layer imports this module; click is a front-end's.""" + tree = ast.parse(Path(dca_mod.__file__).read_text(encoding="utf-8")) + imported = { + alias.name.split(".")[0] for node in ast.walk(tree) if isinstance(node, ast.Import) + for alias in node.names + } | { + (node.module or "").split(".")[0] + for node in ast.walk(tree) + if isinstance(node, ast.ImportFrom) + } + assert "click" not in imported + assert "keel" in imported # the scan saw real imports -- it is not vacuous + + +def test_parse_plan_inputs_accepts_a_budget_and_a_fraction() -> None: + assert parse_plan_inputs("500", "0.1") == PlanInputs(Decimal("500"), Decimal("0.1"), 7) + assert parse_plan_inputs(" 500 ", "0", cadence_days=14).cadence_days == 14 + + +@pytest.mark.parametrize( + ("budget", "buffer", "needle"), + [ + ("0", "0.1", "budget must be positive"), + ("-5", "0.1", "budget must be positive"), + ("abc", "0.1", "is not a number"), + ("NaN", "0.1", "not a finite number"), + ("Infinity", "0.1", "not a finite number"), + ("500", "10", "0.1 means"), # percent-shaped: refused with the fraction hint (R11) + ("500", "1", "0.1 means"), # 1 would plan a spend of zero + ("500", "-0.1", "0.1 means"), + ], +) +def test_parse_plan_inputs_refuses_with_a_named_reason(budget: str, buffer: str, needle: str) -> None: + with pytest.raises(DcaPlanError) as excinfo: + parse_plan_inputs(budget, buffer) + assert needle in str(excinfo.value) + + +def test_parse_plan_inputs_refuses_a_non_positive_cadence() -> None: + with pytest.raises(DcaPlanError, match="cadence"): + parse_plan_inputs("500", "0.1", cadence_days=0) + + +def test_parse_weight() -> None: + assert parse_weight("0.25", "BTC") == Decimal("0.25") + assert parse_weight("0", "BTC") == Decimal("0") + for bad in ("-1", "x", "NaN"): + with pytest.raises(DcaPlanError, match="BTC"): + parse_weight(bad, "BTC") + + +# -- rail 14's cap, read the way the rail reads it -------------------------------------------- + + +@pytest.mark.parametrize( + ("setup", "expected_cap", "reason_needle"), + [ + ("none", Decimal("0"), "no subscription has been attested"), + ("active", Decimal("500"), ""), + ("suspect", Decimal("0"), "suspect"), + ("lapsed", Decimal("0"), "lapsed"), + ("overdue", Decimal("0"), "overdue"), + ], +) +def test_the_cap_is_the_allowance_rail_14_enforces( + setup: str, expected_cap: Decimal, reason_needle: str +) -> None: + """Parity driven through `guards.check` ITSELF (R3): a buy of exactly the cap passes rail 14 + and a buy one cent over is vetoed by it. A copy that drifted from the rail fails here.""" + repo = _unattested_repo() + if setup != "none": + status = { + "active": SubscriptionStatus.ACTIVE, + "suspect": SubscriptionStatus.SUSPECT, + "lapsed": SubscriptionStatus.LAPSED, + "overdue": SubscriptionStatus.ACTIVE, + }[setup] + attest_subscription( + repo, + now_ts=NOW_TS, + free_volume_usd=Decimal("500"), + status=status, + attest_due_ts=NOW_TS - 1 if setup == "overdue" else None, + ) + config = _roomy_config() + + cap = monthly_buy_cap(repo, config, venue="coinbase", now_ts=NOW_TS) + + assert cap.allowance_usd == expected_cap + assert cap.venue == "coinbase" + assert reason_needle in cap.degraded_reason + assert cap.in_force is (reason_needle == "") + over = guards.check(_intent(notional=expected_cap + Decimal("0.01")), repo, config, NOW_TS) + assert _keys(over) & {"monthly_subscription_allowance", "subscription_unattested"} + if expected_cap > 0: + at = guards.check(_intent(notional=expected_cap), repo, config, NOW_TS) + assert not _keys(at) & {"monthly_subscription_allowance", "subscription_unattested"} + + +def test_an_unlimited_tier_has_no_cap() -> None: + repo = _unattested_repo() + attest_subscription(repo, now_ts=NOW_TS, free_volume_usd=None) + cap = monthly_buy_cap(repo, _roomy_config(), venue="coinbase", now_ts=NOW_TS) + assert cap.allowance_usd is None + assert cap.in_force + + +def test_the_cap_is_read_for_the_venue_asked_about() -> None: + """The DEPLOYMENT'S venue (rail 14's key), never a hardcoded coinbase.""" + repo = _unattested_repo() + attest_subscription(repo, now_ts=NOW_TS, free_volume_usd=Decimal("900"), venue="alpaca") + assert monthly_buy_cap(repo, _roomy_config(), venue="alpaca", now_ts=NOW_TS).allowance_usd == ( + Decimal("900") + ) + assert monthly_buy_cap(repo, _roomy_config(), venue="coinbase", now_ts=NOW_TS).allowance_usd == ( + Decimal("0") + ) + + +# -- the command string -------------------------------------------------------------------------- + + +def test_apply_command_never_spells_an_exponent() -> None: + """`Decimal("1e3")` is `1E+3`; a command carrying that would still parse, but a reader copying + it sees a number they did not type. `format(..., "f")` is the only spelling used.""" + inputs = parse_plan_inputs("1e3", "0.10") + assert apply_command(inputs) == "keel dca plan --budget 1000 --buffer-pct 0.10" + + +def test_apply_command_carries_cadence_only_when_not_the_default() -> None: + assert "--cadence-days" not in apply_command(parse_plan_inputs("500", "0.1")) + assert apply_command(parse_plan_inputs("500", "0.1", cadence_days=14)).endswith( + "--cadence-days 14" + ) + + +def test_apply_command_names_the_deployment_before_the_subcommand() -> None: + """R17: global options precede the group, and paths are shell-quoted.""" + command = apply_command( + parse_plan_inputs("500", "0.1"), config_path="/a b/config.yaml", db_path="/x/keel.db" + ) + assert command == ( + "keel --config '/a b/config.yaml' --db /x/keel.db dca plan --budget 500 --buffer-pct 0.1" + ) + + +def test_apply_command_without_inputs_is_the_template() -> None: + assert apply_command(None) == "keel dca plan --budget --buffer-pct " +``` + +- [ ] **Step 2: Run the tests and confirm they fail** + + Run: `uv run pytest -q tests/commands/test_dca_plan.py` + + Expected: FAIL with `ModuleNotFoundError: No module named 'keel.commands.dca_plan'`. + +- [ ] **Step 3: Write the minimal implementation** + +```python +"""`keel dca plan` -- a multi-asset DCA schedule, proposed from this deployment's configuration and +written only on approval, only at `candidate`, through `keel rules add`'s own service. + +**A pure service: no click here** (the same split `keel/commands/rules.py` documents, one step +further -- the web console imports this module, and click is a front-end's). Two front-ends: +`keel/commands/dca.py` (the CLI, which owns the `[Y]/[E]/[N]` loop) and `keel/web/api.py` +(`/api/dca-plan`, read-only). Both call `build_dca_plan`; only the CLI can reach +`apply_dca_plan`, and only from a terminal. + +**Rail 14 is a monthly BUY cap, not a fee waiver (#836).** keel trades on Coinbase Advanced +Trade, where every order pays the venue's fee. `monthly_buy_cap` reads the attested record +exactly as `keel/execution/guards.py` rail 14 does; `tests/commands/test_dca_plan.py` drives +`guards.check` itself to prove the two agree. + +**What this module never does:** promote, touch a `paper`/`live` row, or modify any existing rule. +It writes `candidate` rows through `keel.commands.rules.add_rule_row` and nothing else. +""" + +from __future__ import annotations + +import shlex +from dataclasses import dataclass +from decimal import Decimal, InvalidOperation + +from keel_core.subscription import SubscriptionStatus + +from keel.config import Config +from keel.data.repository import Repository + +#: Average days per month (365.25 / 12 = 30.4375): the per-buy formula's month. +MONTH_DAYS = Decimal("365.25") / Decimal("12") +DEFAULT_CADENCE_DAYS = 7 + + +class DcaPlanError(ValueError): + """An operator input the plan cannot be built from. The message is shown verbatim.""" + + +@dataclass(frozen=True) +class PlanInputs: + budget_usd: Decimal + buffer_pct: Decimal + cadence_days: int = DEFAULT_CADENCE_DAYS + + +def _decimal(raw: str, name: str) -> Decimal: + try: + value = Decimal(str(raw).strip()) + except InvalidOperation as exc: + raise DcaPlanError(f"{name} {raw!r} is not a number") from exc + if not value.is_finite(): + raise DcaPlanError(f"{name} {raw!r} is not a finite number") + return value + + +def parse_plan_inputs( + budget: str, buffer_pct: str, cadence_days: int = DEFAULT_CADENCE_DAYS +) -> PlanInputs: + """The one parse of the plan's inputs, shared by the CLI and `/api/dca-plan` so the two + refuse the same inputs with the same words.""" + budget_usd = _decimal(budget, "budget") + if budget_usd <= 0: + raise DcaPlanError(f"budget must be positive, got {budget!r}") + buffer = _decimal(buffer_pct, "buffer-pct") + if not (Decimal("0") <= buffer < Decimal("1")): + raise DcaPlanError( + f"buffer-pct is a fraction in [0, 1), got {buffer_pct!r} -- 0.1 means hold back 10%" + ) + if cadence_days < 1: + raise DcaPlanError(f"cadence-days must be at least 1, got {cadence_days}") + return PlanInputs(budget_usd=budget_usd, buffer_pct=buffer, cadence_days=cadence_days) + + +def parse_weight(raw: str, asset: str) -> Decimal: + """One `[E]`-edited weight: finite and >= 0 (0 excludes the asset for this session).""" + value = _decimal(raw, f"weight for {asset}") + if value < 0: + raise DcaPlanError(f"weight for {asset} must be 0 or more, got {raw!r}") + return value + + +@dataclass(frozen=True) +class BuyCap: + """Rail 14's monthly BUY cap for one venue. `allowance_usd is None` means unlimited.""" + + venue: str + allowance_usd: Decimal | None + #: "" when the record is in force; otherwise rail 14's own words for why it is not. + degraded_reason: str + pacing: str + + @property + def in_force(self) -> bool: + return self.degraded_reason == "" + + +def monthly_buy_cap(repo: Repository, config: Config, *, venue: str, now_ts: int) -> BuyCap: + """The allowance rail 14 enforces for `venue`, read the way `guards.check` reads it + (`guards.py` rail 14): no record -> `unsubscribed_allowance_usd`; a record -> its + `allowance_usd(now_ts, unsubscribed)`, which already degrades suspect/lapsed/overdue. The + reason words are rail 14's, in its order (lapsed is reported ahead of overdue). Parity is + pinned by driving `guards.check` at the cap and a cent over it.""" + unsubscribed = config.subscription.unsubscribed_allowance_usd + record = repo.get_broker_subscription(venue) + if record is None: + return BuyCap(venue, unsubscribed, "no subscription has been attested", config.subscription.pacing) + effective = record.effective_status(now_ts) + if effective is SubscriptionStatus.ACTIVE: + reason = "" + elif record.status is SubscriptionStatus.LAPSED: + reason = "its subscription is lapsed" + elif record.attest_due_ts <= now_ts: + reason = "its attestation is overdue" + else: + reason = f"its subscription is {effective.value}" + return BuyCap(venue, record.allowance_usd(now_ts, unsubscribed), reason, record.pacing) + + +def apply_command( + inputs: PlanInputs | None, *, config_path: str | None = None, db_path: str | None = None +) -> str: + """The exact `keel dca plan` invocation for `inputs` -- `format(x, "f")` so a `1e3` budget is + spelled `1000`, never `1E+3`. `None` gives the template the web card shows before a budget + is chosen. Global options (`--config`/`--db`) precede the group, as click requires.""" + head = ["keel"] + if config_path is not None: + head += ["--config", shlex.quote(config_path)] + if db_path is not None: + head += ["--db", shlex.quote(db_path)] + if inputs is None: + return " ".join([*head, "dca plan --budget --buffer-pct "]) + parts = [ + *head, + "dca", + "plan", + "--budget", + format(inputs.budget_usd, "f"), + "--buffer-pct", + format(inputs.buffer_pct, "f"), + ] + if inputs.cadence_days != DEFAULT_CADENCE_DAYS: + parts += ["--cadence-days", str(inputs.cadence_days)] + return " ".join(parts) +``` + +The expected string in `test_apply_command_never_spells_an_exponent` is `--buffer-pct 0.10`, because `format(Decimal("0.10"), "f")` keeps the operator's trailing zero. That is intended: the command echoes what was typed. + +- [ ] **Step 4: Run the tests and confirm they pass** + + Run: `uv run pytest -q tests/commands/test_dca_plan.py` + + Expected: PASS. + + If `_unattested_repo`, `_intent`, `_keys`, `_roomy_config` or `NOW_TS` changes name in `tests/execution/test_guards.py`, import the new names. Do not copy the helpers. + +- [ ] **Step 5: Commit** + +```bash +cd /Users/elmehdiaitbrahim/Development/work/CodeGate/keel-wt-dca && git add keel/commands/dca_plan.py tests/commands/test_dca_plan.py && git commit -m "feat(dca): plan inputs and rail 14's monthly buy cap, read as the rail reads it + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 2: The universe, exclusions, existing rules and renormalised weights + +**Files:** +- Modify: `keel/commands/dca_plan.py` +- Test: `tests/commands/test_dca_plan.py` + +**Interfaces:** +- Consumes: + - `build_screen_report(repo, config, screen_fn) -> ScreenReport`, whose `.screened` is a list of `ScreenedProduct(product, asset, facts, result, on_allowlist, attested)`. + - `ScreenFn` from `keel.commands.admission`. + - `Repository.get_rules(status=None) -> list[dict]`, with keys `id`, `kind`, `status`, `params`. + - `keel.execution.guards._asset` (rail 1's key function, imported as `rules.py` does). + - `keel.commands._products._history_product(asset, quote)`. +- Produces: + - `ExclusionReason = Literal["not_on_allowlist", "not_admitted", "no_weight", "has_dca_rule"]`, and `REASON_TEXT: dict[str, str]`. + - `@dataclass(frozen=True) class ExcludedAsset(asset: str, reasons: tuple[ExclusionReason, ...], detail: str)`. + - `@dataclass(frozen=True) class ExistingDcaRule(rule_id: int, asset: str, product_id: str, status: str, budget_usd: Decimal, cadence_days: int)`. + - `@dataclass(frozen=True) class Allocation(asset: str, product_id: str, raw_weight: Decimal, weight: Decimal)`. This is an intermediate. Task 3 turns it into `PlannedBuy`. + - `select_universe(repo, config, *, screen_fn, weights_override=None) -> Universe`, where `@dataclass(frozen=True) class Universe(allocations: tuple[Allocation, ...], excluded: tuple[ExcludedAsset, ...], existing: tuple[ExistingDcaRule, ...], editable: tuple[tuple[str, Decimal], ...])`. + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/commands/test_dca_plan.py`. Move each appended block's imports to the top of the file: ruff's E402 refuses mid-file imports. The same applies in Tasks 3 to 5. + +```python +import json + +from keel.commands.dca_plan import ExcludedAsset, select_universe +from keel.compliance.screen import MarketFacts, ScreenResult +from keel.config import Config +from keel.data.db import connect, migrate +from keel.data.repository import Repository +from keel_core.config import load_config # the conftest YAML is the shared fixture config + + +def _repo() -> Repository: + conn = connect(":memory:") + migrate(conn) + return Repository(conn) + + +def _screen(*rejected: str): + """A fake `screen_fn` admitting every product except the named ASSETS. Injected exactly as + `build_screen_report` takes it -- no candles or attestations needed to reach the plan.""" + + def screen_fn(repo: Repository, product: str, quote: str) -> tuple[MarketFacts, ScreenResult]: + asset = product.split("-")[0] + facts = MarketFacts( + asset=asset, + daily_bars=2000, + median_daily_volume=Decimal("5000000"), + quotable_in_settlement_currency=True, + product_id=product, + venue="coinbase", + ) + admitted = asset not in rejected + failures = [] if admitted else ["history: 12 bars < 1460"] + return facts, ScreenResult(asset=asset, admitted=admitted, failures=failures) + + return screen_fn + + +def _config(valid_config_path: Path, **overrides) -> Config: + """conftest's VALID_CONFIG_YAML: allowlist BTC/ETH/PAXG, weights .40/.30/.30, taker 0.012 by + default, max_per_order_usd 100, dca.budget_usd 50.""" + from dataclasses import replace + + return replace(load_config(str(valid_config_path)), **overrides) + + +def _insert_dca(repo: Repository, product: str, status: str, budget: str = "40") -> int: + return repo.insert_rule( + "dca", + {"product_id": product, "cadence_days": 7, "budget_usd": budget, + "dip_bonus_pct": "0", "lookback_days": 90}, + status=status, + now_ts=NOW_TS, + ) + + +def test_every_admitted_weighted_allowlisted_asset_is_allocated(valid_config_path: Path) -> None: + universe = select_universe(_repo(), _config(valid_config_path), screen_fn=_screen()) + assert [(a.asset, a.product_id, a.weight) for a in universe.allocations] == [ + ("BTC", "BTC-USD", Decimal("0.4")), + ("ETH", "ETH-USD", Decimal("0.3")), + ("PAXG", "PAXG-USD", Decimal("0.3")), + ] + assert universe.excluded == () + assert sum(a.weight for a in universe.allocations) == Decimal("1") + + +def test_a_rejected_asset_is_excluded_with_the_screens_reason_and_weights_renormalise( + valid_config_path: Path, +) -> None: + universe = select_universe(_repo(), _config(valid_config_path), screen_fn=_screen("BTC")) + assert [a.asset for a in universe.allocations] == ["ETH", "PAXG"] + assert [a.weight for a in universe.allocations] == [Decimal("0.5"), Decimal("0.5")] + assert universe.excluded == ( + ExcludedAsset("BTC", ("not_admitted",), "history: 12 bars < 1460"), + ) + + +def test_an_asset_with_no_weight_is_excluded_as_such(valid_config_path: Path) -> None: + config = _config( + valid_config_path, target_weights={"BTC": Decimal("0.5"), "ETH": Decimal("0.5")} + ) + universe = select_universe(_repo(), config, screen_fn=_screen()) + assert [a.asset for a in universe.allocations] == ["BTC", "ETH"] + assert universe.excluded == (ExcludedAsset("PAXG", ("no_weight",), ""),) + + +def test_an_existing_non_disabled_dca_rule_leaves_its_asset_untouched( + valid_config_path: Path, +) -> None: + """Rule 6 on the live account ($/week BTC) is the prime case: listed, unchanged, no new rule.""" + repo = _repo() + rule_id = _insert_dca(repo, "BTC-USD", "live") + _insert_dca(repo, "ETH-USD", "disabled") # disabled does NOT count as existing + + universe = select_universe(repo, _config(valid_config_path), screen_fn=_screen()) + + assert [a.asset for a in universe.allocations] == ["ETH", "PAXG"] + assert universe.excluded == ( + ExcludedAsset("BTC", ("has_dca_rule",), f"rule {rule_id} (live)"), + ) + assert [(e.rule_id, e.asset, e.status, e.budget_usd) for e in universe.existing] == [ + (rule_id, "BTC", "live", Decimal("40")) + ] + + +def test_every_reason_that_applies_is_named(valid_config_path: Path) -> None: + repo = _repo() + _insert_dca(repo, "BTC-USD", "candidate") + config = _config(valid_config_path, target_weights={"ETH": Decimal("1")}) + universe = select_universe(repo, config, screen_fn=_screen("BTC")) + btc = next(e for e in universe.excluded if e.asset == "BTC") + assert btc.reasons == ("not_admitted", "no_weight", "has_dca_rule") + + +def test_a_weight_for_an_asset_off_the_allowlist_is_reported_not_dropped( + valid_config_path: Path, +) -> None: + """R4 / Review Focus 5: a weight never vanishes without a line saying why.""" + config = _config( + valid_config_path, + target_weights={"BTC": Decimal("0.5"), "ETH": Decimal("0.3"), + "PAXG": Decimal("0.1"), "FET": Decimal("0.1")}, + ) + universe = select_universe(_repo(), config, screen_fn=_screen()) + assert ExcludedAsset("FET", ("not_on_allowlist",), "") in universe.excluded + + +def test_weight_keys_are_matched_case_insensitively(valid_config_path: Path) -> None: + """Review Focus 5: `btc: 0.4` in YAML is BTC's weight, not an off-allowlist asset.""" + config = _config( + valid_config_path, + target_weights={"btc": Decimal("0.4"), "Eth": Decimal("0.3"), "PAXG": Decimal("0.3")}, + ) + universe = select_universe(_repo(), config, screen_fn=_screen()) + assert [a.asset for a in universe.allocations] == ["BTC", "ETH", "PAXG"] + assert universe.excluded == () + + +def test_editable_assets_are_admitted_allowlisted_and_without_a_dca_rule( + valid_config_path: Path, +) -> None: + """R14: `[E]` cannot re-admit a rejected asset or stack a rule on an existing one.""" + repo = _repo() + _insert_dca(repo, "BTC-USD", "paper") + universe = select_universe(repo, _config(valid_config_path), screen_fn=_screen("PAXG")) + assert universe.editable == (("ETH", Decimal("0.3")),) + + +def test_a_weights_override_replaces_config_weights_and_zero_excludes( + valid_config_path: Path, +) -> None: + universe = select_universe( + _repo(), + _config(valid_config_path), + screen_fn=_screen(), + weights_override={"BTC": Decimal("1"), "ETH": Decimal("1"), "PAXG": Decimal("0")}, + ) + assert [(a.asset, a.weight) for a in universe.allocations] == [ + ("BTC", Decimal("0.5")), + ("ETH", Decimal("0.5")), + ] + assert ExcludedAsset("PAXG", ("no_weight",), "set to 0 in this session") in universe.excluded + # Edits persist into the next edit's defaults: + assert dict(universe.editable)["PAXG"] == Decimal("0") + + +def test_a_weights_override_for_a_non_editable_asset_is_refused(valid_config_path: Path) -> None: + with pytest.raises(DcaPlanError, match="BTC"): + select_universe( + _repo(), + _config(valid_config_path), + screen_fn=_screen("BTC"), + weights_override={"BTC": Decimal("1")}, + ) + + +def test_selecting_the_universe_writes_nothing(valid_config_path: Path) -> None: + repo = _repo() + before = repo._conn.total_changes # type: ignore[attr-defined] + select_universe(repo, _config(valid_config_path), screen_fn=_screen()) + assert repo._conn.total_changes == before # type: ignore[attr-defined] +``` + +Check before relying on it: `Repository.__init__` stores `self._conn` (see `keel/web/api.py::close_repo`'s note). If `total_changes` stays 0 because nothing ran, the write test is vacuous. Step 4 therefore also runs a mutation (below). + +- [ ] **Step 2: Run the tests and confirm they fail** + + Run: `uv run pytest -q tests/commands/test_dca_plan.py -k "universe or excluded or existing or weight or editable or reason"` + + Expected: FAIL with `ImportError: cannot import name 'select_universe'`. + +- [ ] **Step 3: Write the minimal implementation** + +Add to `keel/commands/dca_plan.py`, in the imports and below `monthly_buy_cap`: + +```python +from collections.abc import Mapping +from typing import Literal + +from keel.commands._products import _history_product +from keel.commands.admission import ScreenFn, build_screen_report + +# Rail 1's own key function, as `keel/commands/rules.py` imports it: an existing rule's asset is +# read the way the rails read it, so "already has a DCA rule" cannot disagree with them. +from keel.execution.guards import _asset as _asset_of + +ExclusionReason = Literal["not_on_allowlist", "not_admitted", "no_weight", "has_dca_rule"] + +#: The operator-facing words for each reason. One table, read by the terminal renderer and the +#: web payload alike. +REASON_TEXT: dict[str, str] = { + "not_on_allowlist": "not on the allowlist", + "not_admitted": "not admitted by the screen", + "no_weight": "no positive target weight", + "has_dca_rule": "already has a DCA rule -- existing, unchanged", +} + +_NON_EXISTING = frozenset({"disabled"}) + + +@dataclass(frozen=True) +class ExcludedAsset: + asset: str + reasons: tuple[ExclusionReason, ...] + detail: str + + +@dataclass(frozen=True) +class ExistingDcaRule: + rule_id: int + asset: str + product_id: str + status: str + budget_usd: Decimal + cadence_days: int + + +@dataclass(frozen=True) +class Allocation: + asset: str + product_id: str + raw_weight: Decimal + #: `raw_weight` renormalised over the allocated set; the allocations' weights sum to 1. + weight: Decimal + + +@dataclass(frozen=True) +class Universe: + allocations: tuple[Allocation, ...] + excluded: tuple[ExcludedAsset, ...] + existing: tuple[ExistingDcaRule, ...] + #: `(asset, current raw weight)` for every asset `[E]` may edit (R14), allowlist order. + editable: tuple[tuple[str, Decimal], ...] + + +def existing_dca_rules(repo: Repository) -> tuple[ExistingDcaRule, ...]: + """Every non-disabled `dca` row, in id order. Read fresh by `apply_dca_plan` too.""" + found: list[ExistingDcaRule] = [] + for row in repo.get_rules(): + if row["kind"] != "dca" or row["status"] in _NON_EXISTING: + continue + params = row["params"] or {} + product = str(params.get("product_id", "")) + found.append( + ExistingDcaRule( + rule_id=int(row["id"]), + asset=_asset_of(product), + product_id=product, + status=str(row["status"]), + budget_usd=Decimal(str(params.get("budget_usd", "0"))), + cadence_days=int(params.get("cadence_days", DEFAULT_CADENCE_DAYS)), + ) + ) + return tuple(found) + + +def select_universe( + repo: Repository, + config: Config, + *, + screen_fn: ScreenFn, + weights_override: Mapping[str, Decimal] | None = None, +) -> Universe: + """allowlist ∩ admitted ∩ positive weight, minus assets with a non-disabled DCA rule. + + Admission comes from `build_screen_report` with the injected `screen_fn` + (`keel.commands.assets.screen_product` in production) -- the one gate every candidate source + routes through, never a laxer copy. READ-ONLY: this writes nothing. + """ + quote = config.quote_currency + allowlist = [asset.upper() for asset in config.allowlist] + weights = {asset.upper(): Decimal(str(w)) for asset, w in config.target_weights.items()} + admitted = { + sp.asset.upper(): sp for sp in build_screen_report(repo, config, screen_fn).screened + } + existing = existing_dca_rules(repo) + existing_by_asset = {rule.asset: rule for rule in existing} + + editable_assets = [ + asset + for asset in allowlist + if asset in admitted and admitted[asset].result.admitted and asset not in existing_by_asset + ] + override = {asset.upper(): w for asset, w in (weights_override or {}).items()} + stray = sorted(set(override) - set(editable_assets)) + if stray: + raise DcaPlanError( + f"cannot edit the weight of {', '.join(stray)}: only admitted, allowlisted assets " + "with no DCA rule are in this plan" + ) + effective = {**weights, **override} + + excluded: list[ExcludedAsset] = [] + chosen: list[tuple[str, Decimal]] = [] + for asset in allowlist: + reasons: list[ExclusionReason] = [] + details: list[str] = [] + screened = admitted.get(asset) + if screened is None or not screened.result.admitted: + reasons.append("not_admitted") + details.append("; ".join(screened.result.failures) if screened else "not screened") + weight = effective.get(asset, Decimal("0")) + if weight <= 0: + reasons.append("no_weight") + if asset in override: + details.append("set to 0 in this session") + rule = existing_by_asset.get(asset) + if rule is not None: + reasons.append("has_dca_rule") + details.append(f"rule {rule.rule_id} ({rule.status})") + if reasons: + excluded.append(ExcludedAsset(asset, tuple(reasons), "; ".join(d for d in details if d))) + else: + chosen.append((asset, weight)) + for asset in sorted(set(weights) - set(allowlist)): + if weights[asset] > 0: + excluded.append(ExcludedAsset(asset, ("not_on_allowlist",), "")) + + total = sum((w for _, w in chosen), Decimal("0")) + allocations = tuple( + Allocation(asset, _history_product(asset, quote), w, w / total) for asset, w in chosen + ) + editable = tuple((asset, effective.get(asset, Decimal("0"))) for asset in editable_assets) + return Universe(allocations, tuple(excluded), existing, editable) +``` + +`_asset_of(product)` is rail 1's key: it uppercases the base leg. Confirm this by reading `guards._asset` (line 290) before relying on the casing. If it does not uppercase, wrap it in `.upper()` here. + +- [ ] **Step 4: Run the tests and confirm they pass, then prove the no-write test can fail** + + Run: `uv run pytest -q tests/commands/test_dca_plan.py` + + Expected: PASS. + + Mutation check. Save a copy of the source first (memory: never `git checkout` uncommitted work): + 1. `cp keel/commands/dca_plan.py /private/tmp/claude-501/dca_plan.bak`. + 2. Insert `repo.set_state("probe", 1)` as the first line of `select_universe`. + 3. Assert the source changed: `grep -c 'set_state("probe"' keel/commands/dca_plan.py` must print `1`. + 4. Run `uv run pytest -q tests/commands/test_dca_plan.py::test_selecting_the_universe_writes_nothing`. Expected: FAIL. + 5. Restore with `cp /private/tmp/claude-501/dca_plan.bak keel/commands/dca_plan.py`, then re-run the file. Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +cd /Users/elmehdiaitbrahim/Development/work/CodeGate/keel-wt-dca && git add keel/commands/dca_plan.py tests/commands/test_dca_plan.py && git commit -m "feat(dca): the plan's universe -- admitted, weighted, allowlisted, no existing DCA rule + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 3: Amounts, fees, the cap check, blockers and warnings (`build_dca_plan`) + +**Files:** +- Modify: `keel/commands/dca_plan.py` +- Test: `tests/commands/test_dca_plan.py` + +**Interfaces:** +- Consumes: `select_universe`, `monthly_buy_cap`, `PlanInputs`, `Config.fees.taker_pct`, `Config.caps.max_per_order_usd`, `Config.dca.budget_usd`. +- Produces: + - `@dataclass(frozen=True) class PlannedBuy(asset: str, product_id: str, weight: Decimal, weight_pct: Decimal, cadence_days: int, per_buy_usd: Decimal, monthly_usd: Decimal, est_monthly_fee_usd: Decimal, min_order_usd: Decimal | None)`. + - `@dataclass(frozen=True) class DcaPlan`, with these fields: + - `inputs: PlanInputs` + - `spend_usd: Decimal` + - `buffer_usd: Decimal` + - `buys: tuple[PlannedBuy, ...]` + - `buy_count: int` + - `excluded: tuple[ExcludedAsset, ...]` + - `existing: tuple[ExistingDcaRule, ...]` + - `editable: tuple[tuple[str, Decimal], ...]` + - `cap: BuyCap` + - `planned_monthly_usd: Decimal` + - `est_monthly_fees_usd: Decimal` + - `taker_pct: Decimal` + - `taker_pct_display: Decimal` (`taker_pct × 100`, computed here so the payload never multiplies) + - `existing_live_monthly_usd: Decimal` + - `executor_budget_usd: Decimal` + - `blockers: tuple[str, ...]` + - `warnings: tuple[str, ...]` + - property `approvable: bool` (`not blockers`) + - `build_dca_plan(repo, config, inputs, *, venue: str, now_ts: int, screen_fn: ScreenFn, weights_override: Mapping[str, Decimal] | None = None) -> DcaPlan`. + - The `DcaPlan` fields hold every figure a front-end shows. The payload computes nothing (`payload.py` Rule 2) and never calls `len()` (Rule 6), which is why `buy_count` exists. + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/commands/test_dca_plan.py`. These figures come from the worked example in Ruling R5: budget 500, buffer 0.1, spend 450.00, weights .4/.3/.3, month 30.4375, cadence 7. + +```python +from keel.commands.dca_plan import build_dca_plan + + +def _plan(valid_config_path: Path, repo: Repository | None = None, *, cap: str | None = "500", + rejected: tuple[str, ...] = (), budget: str = "500", buffer: str = "0.1", + weights_override=None, **config_overrides): + repo = repo or _repo() + if cap is not None: + attest_subscription(repo, now_ts=NOW_TS, free_volume_usd=Decimal(cap)) + return build_dca_plan( + repo, + _config(valid_config_path, **config_overrides), + parse_plan_inputs(budget, buffer), + venue="coinbase", + now_ts=NOW_TS, + screen_fn=_screen(*rejected), + weights_override=weights_override, + ) + + +def test_the_worked_example(valid_config_path: Path) -> None: + plan = _plan(valid_config_path) + assert plan.spend_usd == Decimal("450.00") + assert plan.buffer_usd == Decimal("50.00") + assert [(b.asset, b.cadence_days, b.per_buy_usd, b.monthly_usd, b.est_monthly_fee_usd) + for b in plan.buys] == [ + ("BTC", 7, Decimal("41.39"), Decimal("179.97"), Decimal("2.16")), + ("ETH", 7, Decimal("31.04"), Decimal("134.96"), Decimal("1.62")), + ("PAXG", 7, Decimal("31.04"), Decimal("134.96"), Decimal("1.62")), + ] + assert plan.buy_count == 3 + assert [b.weight_pct for b in plan.buys] == [Decimal("40.0"), Decimal("30.0"), Decimal("30.0")] + assert plan.planned_monthly_usd == Decimal("449.89") + assert plan.est_monthly_fees_usd == Decimal("5.40") + assert plan.taker_pct == Decimal("0.012") + assert plan.taker_pct_display == Decimal("1.200") + assert plan.approvable, plan.blockers + + +def test_the_planned_total_never_exceeds_the_spend_that_was_checked(valid_config_path: Path) -> None: + """R5: every rounding step errs toward spending less.""" + for budget in ("37", "101.01", "999.99", "12345"): + plan = _plan(valid_config_path, budget=budget, buffer="0.05", cap=None) + assert plan.planned_monthly_usd <= plan.spend_usd + + +def test_the_minimum_order_size_is_unknown_and_said_so(valid_config_path: Path) -> None: + plan = _plan(valid_config_path) + assert all(b.min_order_usd is None for b in plan.buys) + assert any("minimum order size" in w for w in plan.warnings) + + +def test_spend_over_the_cap_is_a_blocker_naming_it_a_buy_cap(valid_config_path: Path) -> None: + plan = _plan(valid_config_path, cap="400") # spend 450 > 400 + assert not plan.approvable + (blocker,) = [b for b in plan.blockers if "rail 14" in b] + assert "450.00" in blocker and "400" in blocker and "buy cap" in blocker + + +def test_an_unattested_venue_blocks_with_the_attest_command(valid_config_path: Path) -> None: + """Review Focus 2: not "450 exceeds 0" -- the reason and the fix.""" + plan = _plan(valid_config_path, cap=None) + (blocker,) = [b for b in plan.blockers if "rail 14" in b] + assert "no subscription has been attested" in blocker + assert "keel subscription attest --venue coinbase" in blocker + + +def test_an_unlimited_tier_is_never_a_cap_blocker(valid_config_path: Path) -> None: + repo = _repo() + attest_subscription(repo, now_ts=NOW_TS, free_volume_usd=None) + plan = _plan(valid_config_path, repo, cap=None, budget="100000", buffer="0", + caps=replace_caps(valid_config_path, max_per_order_usd=Decimal("1000000"))) + assert not [b for b in plan.blockers if "rail 14" in b] + + +def test_a_per_buy_that_rounds_to_zero_is_a_named_blocker(valid_config_path: Path) -> None: + """Review Focus 1: named, not a traceback from Dca.__init__ and not a dropped asset.""" + plan = _plan(valid_config_path, budget="1", buffer="0", + weights_override={"BTC": Decimal("0.99"), "ETH": Decimal("0.005"), + "PAXG": Decimal("0.005")}) + assert [b.asset for b in plan.buys] == ["BTC", "ETH", "PAXG"] # not dropped + assert any("ETH" in b and "$0.00" in b for b in plan.blockers) + assert not plan.approvable + + +def test_a_per_buy_over_the_per_order_cap_is_a_blocker(valid_config_path: Path) -> None: + """R8: conftest's max_per_order_usd is 100; BTC's per-buy at a 5000 budget is 459.95.""" + plan = _plan(valid_config_path, budget="5000", buffer="0", cap="100000") + assert any("BTC" in b and "max_per_order_usd" in b for b in plan.blockers) + + +def test_an_empty_universe_is_a_blocker(valid_config_path: Path) -> None: + plan = _plan(valid_config_path, rejected=("BTC", "ETH", "PAXG")) + assert plan.buys == () and plan.buy_count == 0 + assert any("no asset is eligible" in b for b in plan.blockers) + + +def test_existing_live_dca_spend_is_warned_against_the_cap_at_the_executors_figure( + valid_config_path: Path, +) -> None: + """R7: a live row's commitment is config.dca.budget_usd (50) per buy -- what the executor + spends -- not the row's own 40. BTC live weekly: 50 x 30.4375/7 = 217.41 (down to cents). + Planned spend (ETH/PAXG) 450 + 217.41 > 500: a WARNING, not a blocker.""" + repo = _repo() + _insert_dca(repo, "BTC-USD", "live", budget="40") + _insert_dca(repo, "SOL-USD", "candidate", budget="40") # not live: no commitment + plan = _plan(valid_config_path, repo) + assert plan.existing_live_monthly_usd == Decimal("217.41") + assert plan.approvable, plan.blockers + assert any("217.41" in w and "500" in w for w in plan.warnings) + + +def test_the_executor_sizing_contradiction_is_stated_when_amounts_differ( + valid_config_path: Path, +) -> None: + """R9: executor.py sizes every live DCA buy from config.dca.budget_usd (50).""" + plan = _plan(valid_config_path) + assert plan.executor_budget_usd == Decimal("50") + assert any("dca.budget_usd" in w and "$50" in w for w in plan.warnings) + + +def test_even_daily_pacing_is_stated(valid_config_path: Path) -> None: + repo = _repo() + attest_subscription(repo, now_ts=NOW_TS, free_volume_usd=Decimal("500"), pacing="even_daily") + plan = _plan(valid_config_path, repo, cap=None) + assert any("even_daily" in w for w in plan.warnings) +``` + +Add this helper beside `_config`, so the unlimited-tier test can raise the per-order cap without re-parsing YAML: + +```python +def replace_caps(valid_config_path: Path, **caps): + from dataclasses import replace + + return replace(load_config(str(valid_config_path)).caps, **caps) +``` + +- [ ] **Step 2: Run the tests and confirm they fail** + + Run: `uv run pytest -q tests/commands/test_dca_plan.py -k "worked or planned or minimum or cap or zero or empty or existing_live or executor or pacing"` + + Expected: FAIL with `ImportError: cannot import name 'build_dca_plan'`. + +- [ ] **Step 3: Write the minimal implementation** + +Add to `keel/commands/dca_plan.py`: + +```python +from decimal import ROUND_DOWN, ROUND_UP + +_CENT = Decimal("0.01") +_HUNDRED = Decimal("100") + +#: keel records no venue minimum order size: `keel_broker_api.results.Instrument` carries none +#: ("Minimum sizes are still absent for the original reason -- nothing reads them"). +MIN_ORDER_UNKNOWN = ( + "keel does not record the venue's minimum order size (its instrument record carries none), " + "so no per-buy amount was checked against it -- confirm each against the venue's product " + "minimum before promoting." +) + + +@dataclass(frozen=True) +class PlannedBuy: + asset: str + product_id: str + weight: Decimal + #: `weight x 100`, computed here so no front-end multiplies (payload.py Rule 2). + weight_pct: Decimal + cadence_days: int + per_buy_usd: Decimal + monthly_usd: Decimal + est_monthly_fee_usd: Decimal + #: The venue minimum, when keel knows it. It never does today -- see MIN_ORDER_UNKNOWN. + min_order_usd: Decimal | None + + +@dataclass(frozen=True) +class DcaPlan: + inputs: PlanInputs + spend_usd: Decimal + buffer_usd: Decimal + buys: tuple[PlannedBuy, ...] + buy_count: int + excluded: tuple[ExcludedAsset, ...] + existing: tuple[ExistingDcaRule, ...] + editable: tuple[tuple[str, Decimal], ...] + cap: BuyCap + planned_monthly_usd: Decimal + est_monthly_fees_usd: Decimal + taker_pct: Decimal + taker_pct_display: Decimal + existing_live_monthly_usd: Decimal + executor_budget_usd: Decimal + blockers: tuple[str, ...] + warnings: tuple[str, ...] + + @property + def approvable(self) -> bool: + return not self.blockers + + +def _cents_down(value: Decimal) -> Decimal: + return value.quantize(_CENT, rounding=ROUND_DOWN) + + +def _usd(value: Decimal) -> str: + return f"${format(value.quantize(_CENT), ',f')}" + + +def build_dca_plan( + repo: Repository, + config: Config, + inputs: PlanInputs, + *, + venue: str, + now_ts: int, + screen_fn: ScreenFn, + weights_override: Mapping[str, Decimal] | None = None, +) -> DcaPlan: + """The whole proposal, every figure computed here and nowhere downstream. READ-ONLY. + + Per buy: `monthly share x cadence_days / (365.25/12)`, rounded DOWN to cents; the monthly + total is recomputed from that rounded buy and rounded down again; the fee estimate at the + CONFIGURED `fees.taker_pct` is rounded UP (R5). Blockers (R6, R8) make the plan + unapprovable; warnings (R7, R9, the minimum-order gap, even_daily pacing) never do. + """ + universe = select_universe(repo, config, screen_fn=screen_fn, weights_override=weights_override) + cap = monthly_buy_cap(repo, config, venue=venue, now_ts=now_ts) + cadence = inputs.cadence_days + spend = _cents_down(inputs.budget_usd * (Decimal("1") - inputs.buffer_pct)) + taker = config.fees.taker_pct + + buys: list[PlannedBuy] = [] + blockers: list[str] = [] + for allocation in universe.allocations: + per_buy = _cents_down(spend * allocation.weight * cadence / MONTH_DAYS) + monthly = _cents_down(per_buy * MONTH_DAYS / cadence) + buys.append( + PlannedBuy( + asset=allocation.asset, + product_id=allocation.product_id, + weight=allocation.weight, + weight_pct=(allocation.weight * _HUNDRED).quantize(Decimal("0.1")), + cadence_days=cadence, + per_buy_usd=per_buy, + monthly_usd=monthly, + est_monthly_fee_usd=(monthly * taker).quantize(_CENT, rounding=ROUND_UP), + min_order_usd=None, + ) + ) + if per_buy <= 0: + blockers.append( + f"{allocation.asset}'s per-buy rounds to $0.00 -- raise the budget or its weight" + ) + elif per_buy > config.caps.max_per_order_usd: + blockers.append( + f"{allocation.asset}'s per-buy {_usd(per_buy)} exceeds caps.max_per_order_usd " + f"{_usd(config.caps.max_per_order_usd)}; that rail would veto every buy" + ) + if not buys: + blockers.append("no asset is eligible -- see the excluded list for each reason") + if cap.allowance_usd is not None and spend > cap.allowance_usd: + if cap.in_force: + blockers.append( + f"planned spend {_usd(spend)} exceeds rail 14's monthly buy cap " + f"{_usd(cap.allowance_usd)} on {cap.venue}" + ) + else: + blockers.append( + f"rail 14's monthly buy cap on {cap.venue} is {_usd(cap.allowance_usd)} because " + f"{cap.degraded_reason}; planned spend is {_usd(spend)}. Run `keel subscription " + f"attest --venue {cap.venue} --tier ` to restore it." + ) + + executor_budget = config.dca.budget_usd + live_monthly = sum( + ( + _cents_down(executor_budget * MONTH_DAYS / rule.cadence_days) + for rule in universe.existing + if rule.status == "live" + ), + Decimal("0"), + ) + warnings: list[str] = [MIN_ORDER_UNKNOWN] + if cap.allowance_usd is not None and live_monthly > 0 and spend + live_monthly > cap.allowance_usd: + warnings.append( + f"existing live DCA rules commit about {_usd(live_monthly)}/month; with this plan's " + f"{_usd(spend)} that exceeds rail 14's monthly buy cap {_usd(cap.allowance_usd)}, " + "which will veto buys once the month's total reaches it" + ) + if cap.pacing == "even_daily": + warnings.append( + "rail 14 pacing is even_daily: the cap is also paced per business day, so an early-" + "month buy can be vetoed below the full-month figure shown" + ) + if any(b.per_buy_usd != executor_budget for b in buys): + warnings.append( + f"the live executor sizes EVERY DCA buy from config dca.budget_usd " + f"({_usd(executor_budget)}), not the per-asset amounts here " + "(keel/execution/executor.py) -- once promoted to live, each of these rules spends " + f"{_usd(executor_budget)} per buy until that changes" + ) + + return DcaPlan( + inputs=inputs, + spend_usd=spend, + buffer_usd=_cents_down(inputs.budget_usd) - spend, + buys=tuple(buys), + buy_count=len(buys), + excluded=universe.excluded, + existing=universe.existing, + editable=universe.editable, + cap=cap, + planned_monthly_usd=sum((b.monthly_usd for b in buys), Decimal("0")), + est_monthly_fees_usd=sum((b.est_monthly_fee_usd for b in buys), Decimal("0")), + taker_pct=taker, + taker_pct_display=taker * _HUNDRED, + existing_live_monthly_usd=live_monthly, + executor_budget_usd=executor_budget, + blockers=tuple(blockers), + warnings=tuple(warnings), + ) +``` + +The R9 warning's `$50` must match the test's `"$50"`. `_usd(Decimal("50"))` is `$50.00`, and `"$50"` is a prefix of it, so the test holds. + +`_usd` uses `format(x, ",f")`. The payload does its own formatting, and this helper only serves the service's own sentences. + +- [ ] **Step 4: Run the tests and confirm they pass** + + Run: `uv run pytest -q tests/commands/test_dca_plan.py` + + Expected: PASS. + + If `test_the_worked_example`'s fee total differs, recompute by hand: 2.16 + 1.62 + 1.62 = 5.40. Do not loosen the assertion. + +- [ ] **Step 5: Commit** + +```bash +cd /Users/elmehdiaitbrahim/Development/work/CodeGate/keel-wt-dca && git add keel/commands/dca_plan.py tests/commands/test_dca_plan.py && git commit -m "feat(dca): per-buy amounts, fees at the configured rate, rail 14 blockers and warnings + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 4: The terminal renderer, with rail 14's wording pinned + +**Files:** +- Modify: `keel/commands/dca_plan.py` +- Test: `tests/commands/test_dca_plan.py`, `tests/test_rail14_is_a_buy_cap.py` + +**Interfaces:** +- Consumes: `DcaPlan`, `REASON_TEXT`. +- Produces: `render_dca_plan(plan: DcaPlan) -> list[str]`, the exact lines the CLI echoes, and `RAIL14_NOTE: str`. + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/commands/test_dca_plan.py`. The assertions are structural: they count rows and check pairings, and a single substring match is never the whole test. + +```python +from keel.commands.dca_plan import RAIL14_NOTE, render_dca_plan + + +def _section(lines: list[str], title: str) -> list[str]: + """The lines under a `== title ==` header, up to the next header.""" + start = lines.index(f"== {title} ==") + rest = lines[start + 1 :] + end = next((i for i, line in enumerate(rest) if line.startswith("== ")), len(rest)) + return [line for line in rest[:end] if line.strip()] + + +def test_the_schedule_has_one_row_per_buy_carrying_its_figures(valid_config_path: Path) -> None: + plan = _plan(valid_config_path) + rows = _section(render_dca_plan(plan), "Schedule") + body = [row for row in rows if not row.lstrip().startswith(("asset", "total"))] + assert len(body) == plan.buy_count == 3 + for row, buy in zip(body, plan.buys, strict=True): + cells = row.split() + assert cells[0] == buy.asset + assert "every 7 days" in row + assert f"${buy.per_buy_usd}" in row and f"${buy.monthly_usd}" in row + (total,) = [row for row in rows if row.lstrip().startswith("total")] + assert "$449.89" in total and "$5.40" in total + + +def test_fees_are_labelled_as_the_configured_rate(valid_config_path: Path) -> None: + text = "\n".join(render_dca_plan(_plan(valid_config_path))) + assert "configured fees.taker_pct 1.2%" in text + + +def test_every_excluded_asset_appears_once_with_its_reasons(valid_config_path: Path) -> None: + repo = _repo() + _insert_dca(repo, "BTC-USD", "live") + plan = _plan(valid_config_path, repo, rejected=("PAXG",)) + rows = _section(render_dca_plan(plan), "Excluded") + assert len(rows) == len(plan.excluded) == 2 + assert {row.split()[0] for row in rows} == {"BTC", "PAXG"} + btc = next(row for row in rows if row.split()[0] == "BTC") + assert "existing, unchanged" in btc + + +def test_existing_rules_are_listed_unchanged(valid_config_path: Path) -> None: + repo = _repo() + rule_id = _insert_dca(repo, "BTC-USD", "live") + rows = _section(render_dca_plan(_plan(valid_config_path, repo)), "Existing DCA rules (unchanged)") + assert len(rows) == 1 and rows[0].split()[0] == f"[{rule_id}]" + + +def test_blockers_and_warnings_each_get_one_line(valid_config_path: Path) -> None: + plan = _plan(valid_config_path, cap="400") + lines = render_dca_plan(plan) + assert len(_section(lines, "Cannot approve")) == len(plan.blockers) + assert len(_section(lines, "Notes")) == len(plan.warnings) + + +def test_an_approvable_plan_has_no_cannot_approve_section(valid_config_path: Path) -> None: + assert "== Cannot approve ==" not in render_dca_plan(_plan(valid_config_path)) +``` + +Add to `tests/test_rail14_is_a_buy_cap.py`. It uses that file's existing `_claims` detector, so the claim can fail the build: + +```python +def test_the_dca_plan_calls_rail_14_a_buy_cap_and_never_fee_free() -> None: + """#836: the plan prints rail 14 as a monthly BUY cap and says it is not a fee waiver.""" + from keel.commands.dca_plan import RAIL14_NOTE + + text = RAIL14_NOTE.lower() + assert "buy cap" in text + assert "not a fee waiver" in text + assert "fee-free" not in text + assert not _claims(RAIL14_NOTE) +``` + +Add to `tests/commands/test_dca_plan.py` a whole-output check that runs over a real plan: + +```python +def test_the_rendered_plan_states_rail_14_is_a_buy_cap_and_never_fee_free( + valid_config_path: Path, +) -> None: + lines = render_dca_plan(_plan(valid_config_path)) + assert sum(RAIL14_NOTE in line for line in lines) == 1 + assert not any("fee-free" in line.lower() for line in lines) +``` + +- [ ] **Step 2: Run the tests and confirm they fail** + + Run: `uv run pytest -q tests/commands/test_dca_plan.py tests/test_rail14_is_a_buy_cap.py` + + Expected: FAIL with `ImportError: cannot import name 'RAIL14_NOTE'`. + +- [ ] **Step 3: Write the minimal implementation** + +```python +#: Stated once per plan, verbatim (#836). The attested figure is a cap keel imposes on its own +#: buying; Advanced Trade charges its fee on every order regardless. +RAIL14_NOTE = ( + "Rail 14 is a monthly BUY cap keel imposes on its own buying -- not a fee waiver: every " + "order pays the venue's fee." +) + + +def render_dca_plan(plan: DcaPlan) -> list[str]: + """The CLI's exact lines. Sections are `== Title ==` headers so the CLI and tests read them + the same way.""" + inputs = plan.inputs + cap = "unlimited" if plan.cap.allowance_usd is None else _usd(plan.cap.allowance_usd) + lines = [ + "== DCA plan ==", + f" budget {_usd(inputs.budget_usd)}/month, buffer {format(inputs.buffer_pct, 'f')} " + f"({_usd(plan.buffer_usd)} held back) -> spend {_usd(plan.spend_usd)}/month", + f" rail 14 monthly buy cap on {plan.cap.venue}: {cap}" + + ("" if plan.cap.in_force else f" (because {plan.cap.degraded_reason})"), + f" {RAIL14_NOTE}", + "", + "== Schedule ==", + f" {'asset':<8} {'weight':>7} {'cadence':<14} {'per buy':>10} {'monthly':>10} {'est. fee':>9}", + ] + for buy in plan.buys: + lines.append( + f" {buy.asset:<8} {format(buy.weight_pct, 'f') + '%':>7} " + f"{'every ' + str(buy.cadence_days) + ' days':<14} {_usd(buy.per_buy_usd):>10} " + f"{_usd(buy.monthly_usd):>10} {_usd(buy.est_monthly_fee_usd):>9}" + ) + lines.append( + f" total {_usd(plan.planned_monthly_usd)}/month, est. fees {_usd(plan.est_monthly_fees_usd)}" + f"/month at the configured fees.taker_pct {_trim_pct(plan.taker_pct_display)}%" + ) + if plan.excluded: + lines += ["", "== Excluded =="] + for item in plan.excluded: + reasons = ", ".join(REASON_TEXT[r] for r in item.reasons) + detail = f" ({item.detail})" if item.detail else "" + lines.append(f" {item.asset} -- {reasons}{detail}") + if plan.existing: + lines += ["", "== Existing DCA rules (unchanged) =="] + for rule in plan.existing: + lines.append( + f" [{rule.rule_id}] {rule.product_id} {rule.status} " + f"{_usd(rule.budget_usd)} every {rule.cadence_days} days" + ) + if plan.blockers: + lines += ["", "== Cannot approve =="] + [f" ✗ {b}" for b in plan.blockers] + lines += ["", "== Notes =="] + [f" ! {w}" for w in plan.warnings] + return lines + + +def _trim_pct(value: Decimal) -> str: + """`1.200` -> `1.2`; `format(..., "f")` keeps it exponent-free, and `.rstrip` only trims.""" + text = format(value, "f") + return text.rstrip("0").rstrip(".") if "." in text else text +``` + +The schedule row's first cell is the asset, because the test splits on whitespace. The asset code is never padded with anything but trailing spaces. + +- [ ] **Step 4: Run the tests and confirm they pass** + + Run: `uv run pytest -q tests/commands/test_dca_plan.py tests/test_rail14_is_a_buy_cap.py` + + Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +cd /Users/elmehdiaitbrahim/Development/work/CodeGate/keel-wt-dca && git add keel/commands/dca_plan.py tests/commands/test_dca_plan.py tests/test_rail14_is_a_buy_cap.py && git commit -m "feat(dca): render the plan; rail 14 is stated as a monthly buy cap, never fee-free + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 5: `apply_dca_plan`, the all-or-nothing `candidate` write + +**Files:** +- Modify: `keel/commands/dca_plan.py` +- Test: `tests/commands/test_dca_plan.py` + +**Interfaces:** +- Consumes: + - `keel.commands.rules.add_rule_row(repo, config, *, kind, product, params_json, now_ts, echo, echo_err) -> RulesOutcome`. + - `keel.commands.rules.RulesRefused` and `RulesUsageError`. + - `keel.commands._products.parse_products_option(product, config)`. + - `keel.agent.build_rule_from_params(kind, params)`. + - `existing_dca_rules`. +- Produces: + - `class DcaPlanRefused(RuntimeError)`. Its message is operator-facing, and nothing was written when it raises before the first insert. + - `apply_dca_plan(repo, config, plan, *, now_ts: int, echo=_noop, echo_err=_noop) -> tuple[RulesOutcome, ...]`. + +- [ ] **Step 1: Write the failing tests** + +```python +from keel.commands.dca_plan import DcaPlanRefused, apply_dca_plan + + +def _rows(repo: Repository) -> list[dict]: + return [dict(row) for row in repo.get_rules()] + + +def test_approval_writes_one_candidate_dca_rule_per_buy_through_rules_add( + valid_config_path: Path, +) -> None: + repo = _repo() + plan = _plan(valid_config_path, repo) + outcomes = apply_dca_plan(repo, _config(valid_config_path), plan, now_ts=NOW_TS) + + rows = _rows(repo) + assert len(outcomes) == len(rows) == plan.buy_count == 3 + for row, buy, outcome in zip(rows, plan.buys, outcomes, strict=True): + assert (row["kind"], row["status"]) == ("dca", "candidate") + assert row["params"]["product_id"] == buy.product_id + assert row["params"]["cadence_days"] == 7 + assert Decimal(row["params"]["budget_usd"]) == buy.per_buy_usd + assert outcome.rule_id == row["id"] and outcome.new_status == "candidate" + # The row round-trips through the agent's own reconstruction (what `rules add` guarantees): + from keel.agent import _build_rule + + rebuilt = _build_rule(rows[0]) + assert rebuilt.params["budget_usd"] == Decimal("41.39") + + +def test_approval_never_touches_an_existing_rule(valid_config_path: Path) -> None: + repo = _repo() + live_id = _insert_dca(repo, "BTC-USD", "live") + other_id = repo.insert_rule("turtle_breakout", {"product_id": "ETH-USD"}, status="paper", + now_ts=NOW_TS) + before = {row["id"]: row for row in _rows(repo)} + + apply_dca_plan(repo, _config(valid_config_path), _plan(valid_config_path, repo), now_ts=NOW_TS) + + after = {row["id"]: row for row in _rows(repo)} + assert after[live_id] == before[live_id] and after[other_id] == before[other_id] + new = [row for rid, row in after.items() if rid not in before] + assert {row["params"]["product_id"] for row in new} == {"ETH-USD", "PAXG-USD"} + assert {row["status"] for row in new} == {"candidate"} + + +def test_a_blocked_plan_writes_nothing(valid_config_path: Path) -> None: + repo = _repo() + plan = _plan(valid_config_path, repo, cap="400") + with pytest.raises(DcaPlanRefused, match="rail 14"): + apply_dca_plan(repo, _config(valid_config_path), plan, now_ts=NOW_TS) + assert _rows(repo) == [] + + +def test_a_dca_rule_written_after_the_preview_refuses_the_whole_approval( + valid_config_path: Path, +) -> None: + """Review Focus 3 / R2: the user's concurrent session adds ETH's DCA rule mid-prompt.""" + repo = _repo() + plan = _plan(valid_config_path, repo) + late = _insert_dca(repo, "ETH-USD", "candidate") + + with pytest.raises(DcaPlanRefused, match="ETH"): + apply_dca_plan(repo, _config(valid_config_path), plan, now_ts=NOW_TS) + assert [row["id"] for row in _rows(repo)] == [late] + + +def test_a_buy_the_rule_cannot_construct_is_refused_before_any_write( + valid_config_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """R2: pre-validation covers every buy before the first insert.""" + repo = _repo() + plan = _plan(valid_config_path, repo) + from keel import agent + + real = agent.build_rule_from_params + + def refuse_paxg(kind, params): + if params["product_id"] == "PAXG-USD": + raise ValueError("budget_usd must be positive") + return real(kind, params) + + monkeypatch.setattr(agent, "build_rule_from_params", refuse_paxg) + with pytest.raises(DcaPlanRefused, match="PAXG"): + apply_dca_plan(repo, _config(valid_config_path), plan, now_ts=NOW_TS) + assert _rows(repo) == [] +``` + +The monkeypatch must reach the code that is running. `dca_plan.py` must call `agent.build_rule_from_params` through the module attribute (`from keel import agent`), not bind the name at import. Otherwise the patch never reaches the reader and the test passes by vacuum. Step 4 checks this. + +- [ ] **Step 2: Run the tests and confirm they fail** + + Run: `uv run pytest -q tests/commands/test_dca_plan.py -k "approval or blocked or refused or construct"` + + Expected: FAIL with `ImportError: cannot import name 'apply_dca_plan'`. + +- [ ] **Step 3: Write the minimal implementation** + +```python +import json +from collections.abc import Callable + +from keel import agent +from keel.commands._products import parse_products_option +from keel.commands.rules import RulesOutcome, RulesRefused, RulesUsageError, add_rule_row + + +class DcaPlanRefused(RuntimeError): + """Approval refused. Raised before the first insert, so nothing was written -- except the + one case R2 names (a row failing AFTER pre-validation), whose message lists what was.""" + + +def _noop(message: str) -> None: + del message + + +def _rule_params(buy: PlannedBuy) -> dict[str, object]: + return {"cadence_days": buy.cadence_days, "budget_usd": format(buy.per_buy_usd, "f")} + + +def apply_dca_plan( + repo: Repository, + config: Config, + plan: DcaPlan, + *, + now_ts: int, + echo: Callable[[str], None] = _noop, + echo_err: Callable[[str], None] = _noop, +) -> tuple[RulesOutcome, ...]: + """Write one `candidate` `dca` rule per planned buy, through `keel rules add`'s own service. + + All-or-nothing (R2): refuse a blocked plan; re-read the rules table and refuse if any planned + asset gained a non-disabled DCA rule since the preview; construct every rule (rails 18/19 + + `build_rule_from_params`) before the FIRST insert. Never promotes; never writes paper/live; + never touches an existing row -- `add_rule_row` writes `candidate` and nothing else. + """ + if plan.blockers: + raise DcaPlanRefused("the plan cannot be approved: " + "; ".join(plan.blockers)) + fresh = {rule.asset: rule for rule in existing_dca_rules(repo)} + stale = [buy.asset for buy in plan.buys if buy.asset in fresh] + if stale: + raise DcaPlanRefused( + f"{', '.join(stale)} gained a DCA rule since this plan was shown " + f"({', '.join(f'rule {fresh[a].rule_id}' for a in stale)}); nothing was written -- " + "re-run `keel dca plan` to see the current state" + ) + for buy in plan.buys: + try: + parse_products_option(buy.product_id, config) + agent.build_rule_from_params("dca", {**_rule_params(buy), "product_id": buy.product_id}) + except (ValueError, TypeError, ArithmeticError) as exc: + raise DcaPlanRefused(f"{buy.asset}: {exc}; nothing was written") from exc + + outcomes: list[RulesOutcome] = [] + for buy in plan.buys: + try: + outcomes.append( + add_rule_row( + repo, + config, + kind="dca", + product=buy.product_id, + params_json=json.dumps(_rule_params(buy)), + now_ts=now_ts, + echo=echo, + echo_err=echo_err, + ) + ) + except (RulesRefused, RulesUsageError) as exc: + written = ", ".join(str(o.rule_id) for o in outcomes) or "none" + raise DcaPlanRefused( + f"{buy.asset}: {exc}; rules already written: {written}" + ) from exc + return tuple(outcomes) +``` + +- [ ] **Step 4: Run the tests, confirm they pass, and prove the patch reaches the reader** + + Run: `uv run pytest -q tests/commands/test_dca_plan.py` + + Expected: PASS. + + Check that `test_a_buy_the_rule_cannot_construct_is_refused_before_any_write` is not vacuous: + 1. Save a copy with `cp keel/commands/dca_plan.py /private/tmp/claude-501/dca_plan.bak`. + 2. Delete the pre-validation `for` loop. + 3. Confirm the source changed: `grep -c 'agent.build_rule_from_params("dca"' keel/commands/dca_plan.py` must print `0`. + 4. Run the test. Expected: FAIL, because rows are written before PAXG refuses inside `add_rule_row`. + 5. Restore from the copy and re-run. Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +cd /Users/elmehdiaitbrahim/Development/work/CodeGate/keel-wt-dca && git add keel/commands/dca_plan.py tests/commands/test_dca_plan.py && git commit -m "feat(dca): approve writes one candidate dca rule per asset via rules add, all or nothing + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 6: The `keel dca plan` CLI (TTY loop, off-TTY preview, registration, isolation pins) + +**Files:** +- Create: `keel/commands/dca.py` +- Modify: `keel/cli.py`, which imports `from keel.commands.dca import dca_group` beside `from keel.commands.rules import ...` (line ~170) and calls `cli.add_command(dca_group)` after `cli.add_command(rules_group)` (line ~1212). +- Modify: `tests/commands/test_service_isolation.py`, adding `"keel.commands.dca"` and `"keel.commands.dca_plan"` to `SERVICE_MODULES`, in alphabetical position. +- Test: `tests/commands/test_dca_cli.py` + +**Interfaces:** +- Consumes: + - `parse_plan_inputs`, `parse_weight`, `build_dca_plan`, `render_dca_plan` and `apply_dca_plan` from Tasks 1 to 5. + - `keel.commands.assets.screen_product`. + - `keel.commands._common`, whose `_is_interactive()` is reached as the module attribute `_common._is_interactive()`, per `_common`'s docstring. + - `_open_repo`, `_load_cfg`, `_bound_venue_or_default` and `with_disclaimer` from `_common`. +- Produces: `dca_group` (the click group `dca`) with the command `plan`. Tests patch `keel.commands.dca.screen_product` and `keel.commands._common._is_interactive`. + +- [ ] **Step 1: Write the failing tests** + +```python +"""`keel dca plan` -- the thin CLI over `keel.commands.dca_plan`. + +Pins: off a TTY it prints and writes NOTHING (exit 0 approvable / 1 blocked, R12); at a TTY, +[Y] writes candidates, [N] writes nothing, [E] re-renders with edited weights before [Y]. +""" + +from __future__ import annotations + +import sqlite3 +import time +from decimal import Decimal +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from keel.cli import cli +from keel.commands import _common +from keel.commands import dca as dca_cli +from keel.data.db import connect, migrate +from keel.data.repository import Repository +from tests.commands.test_dca_plan import _screen +from tests.conftest import attest_subscription + + +def _repo(db: Path) -> Repository: + conn = connect(str(db)) + migrate(conn) + return Repository(conn) + + +@pytest.fixture +def deployment(tmp_path: Path, valid_config_path: Path, monkeypatch: pytest.MonkeyPatch): + db = tmp_path / "t.db" + attest_subscription(_repo(db), now_ts=int(time.time()), free_volume_usd=Decimal("500")) + monkeypatch.setattr(dca_cli, "screen_product", _screen()) + return db, valid_config_path + + +def _run(deployment, *args: str, input: str | None = None): + db, config = deployment + return CliRunner().invoke( + cli, + ["--db", str(db), "--config", str(config), "dca", "plan", *args], + input=input, + ) + + +def test_off_a_tty_it_prints_the_plan_and_writes_nothing(deployment, monkeypatch) -> None: + monkeypatch.setattr(_common, "_is_interactive", lambda: False) + # `PRAGMA data_version` on ONE connection held across the run changes iff ANOTHER connection + # committed -- `total_changes` would not do here, it is per-connection. The fixture already + # migrated the file, so an up-to-date `migrate()` inside the command has nothing to commit. + watcher = sqlite3.connect(str(deployment[0])) + before = watcher.execute("PRAGMA data_version").fetchone()[0] + + result = _run(deployment, "--budget", "500", "--buffer-pct", "0.1") + + assert result.exit_code == 0, result.output + assert "== Schedule ==" in result.output and "$41.39" in result.output + assert "nothing written" in result.output + assert watcher.execute("PRAGMA data_version").fetchone()[0] == before + watcher.close() + assert _repo(deployment[0]).get_rules() == [] + + +def test_off_a_tty_a_blocked_plan_exits_1(deployment, monkeypatch) -> None: + monkeypatch.setattr(_common, "_is_interactive", lambda: False) + result = _run(deployment, "--budget", "5000", "--buffer-pct", "0") # over the 500 cap + assert result.exit_code == 1 + assert "== Cannot approve ==" in result.output + + +def test_yes_at_a_tty_writes_candidates(deployment, monkeypatch) -> None: + monkeypatch.setattr(_common, "_is_interactive", lambda: True) + result = _run(deployment, "--budget", "500", "--buffer-pct", "0.1", input="Y\n") + assert result.exit_code == 0, result.output + rows = _repo(deployment[0]).get_rules() + assert [(r["kind"], r["status"]) for r in rows] == [("dca", "candidate")] * 3 + assert result.output.count("added rule ") == 3 + + +def test_no_at_a_tty_writes_nothing(deployment, monkeypatch) -> None: + monkeypatch.setattr(_common, "_is_interactive", lambda: True) + result = _run(deployment, "--budget", "500", "--buffer-pct", "0.1", input="N\n") + assert result.exit_code == 0 + assert _repo(deployment[0]).get_rules() == [] + assert "cancelled" in result.output.lower() + + +def test_edit_renormalises_and_reshows_before_approval(deployment, monkeypatch) -> None: + """[E]: BTC 1, ETH 1, PAXG 0 -> BTC/ETH at 50% each, PAXG excluded, then [Y].""" + monkeypatch.setattr(_common, "_is_interactive", lambda: True) + result = _run( + deployment, "--budget", "500", "--buffer-pct", "0.1", input="E\n1\n1\n0\nY\n" + ) + assert result.exit_code == 0, result.output + assert result.output.count("== Schedule ==") == 2 # shown, edited, re-shown + assert "set to 0 in this session" in result.output + rows = _repo(deployment[0]).get_rules() + assert {r["params"]["product_id"] for r in rows} == {"BTC-USD", "ETH-USD"} + assert {Decimal(r["params"]["budget_usd"]) for r in rows} == {Decimal("51.74")} + + +def test_a_blocked_plan_at_a_tty_does_not_offer_approve(deployment, monkeypatch) -> None: + monkeypatch.setattr(_common, "_is_interactive", lambda: True) + result = _run(deployment, "--budget", "5000", "--buffer-pct", "0", input="Y\nN\n") + assert "[Y] Approve" not in result.output + assert _repo(deployment[0]).get_rules() == [] + + +def test_bad_inputs_are_click_usage_errors(deployment) -> None: + result = _run(deployment, "--budget", "500", "--buffer-pct", "10") + assert result.exit_code == 2 + assert "0.1 means" in result.output + + +def test_buffer_pct_is_required(deployment) -> None: + result = _run(deployment, "--budget", "500") + assert result.exit_code == 2 + assert "--buffer-pct" in result.output + + +def test_the_disclaimer_is_printed(deployment, monkeypatch) -> None: + monkeypatch.setattr(_common, "_is_interactive", lambda: False) + assert _common.DISCLAIMER in _run(deployment, "--budget", "500", "--buffer-pct", "0.1").output +``` + +`test_edit_renormalises...` assumes the edit prompts in `editable` order (BTC, ETH, PAXG). The expected per-buy of 51.74 is the R5 renorm figure: 225 × 7 / 30.4375 = 51.7454, rounded down to 51.74. + +- [ ] **Step 2: Run the tests and confirm they fail** + + Run: `uv run pytest -q tests/commands/test_dca_cli.py` + + Expected: FAIL with `ImportError: cannot import name 'dca' from 'keel.commands'`. + +- [ ] **Step 3: Write the minimal implementation** + +`keel/commands/dca.py`: + +```python +"""`keel dca` -- the CLI front-end over `keel.commands.dca_plan` (the service). + +Thin on purpose: parse options, run the `[Y]/[E]/[N]` loop, echo the service's lines. Every +figure, refusal and write lives in the service, which `keel/web/api.py` calls too. + +**The terminal is load-bearing, the heavier gate is not** -- `keel/commands/journal.py`'s +reasoning. Writing `candidate` rules is what `keel rules add` does ungated: a candidate cannot +trade until `keel rules promote` clears it, so `_require_interactive_confirmation`'s typed `yes` +would be ceremony. But a prompt needs a human, so off a TTY this prints and writes nothing, +using the same predicate (`_common._is_interactive`, attribute access, the one patch point). +""" + +from __future__ import annotations + +import time +from decimal import Decimal + +import click + +from keel.commands import _common +from keel.commands._common import _bound_venue_or_default, _load_cfg, _open_repo, with_disclaimer +from keel.commands.assets import screen_product +from keel.commands.dca_plan import ( + DEFAULT_CADENCE_DAYS, + DcaPlanError, + DcaPlanRefused, + apply_dca_plan, + build_dca_plan, + parse_plan_inputs, + parse_weight, + render_dca_plan, +) + + +@click.group("dca") +def dca_group() -> None: + """Plan a multi-asset DCA schedule (writes `candidate` rules only, on approval).""" + + +@dca_group.command("plan") +@click.option("--budget", required=True, help="Monthly budget in USD, e.g. 500.") +@click.option( + "--buffer-pct", + required=True, + help="Fraction of the budget held back, in [0, 1): 0.1 holds back 10%.", +) +@click.option( + "--cadence-days", + type=int, + default=DEFAULT_CADENCE_DAYS, + show_default=True, + help="Days between buys for every asset in the plan.", +) +@click.pass_context +@with_disclaimer +def dca_plan_cmd(ctx: click.Context, budget: str, buffer_pct: str, cadence_days: int) -> None: + """Propose a DCA schedule over the admitted, weighted allowlist. + + Spend is budget x (1 - buffer-pct), and must fit rail 14's monthly BUY cap (a cap keel + imposes on its own buying -- not a fee waiver). At a terminal: [Y] writes one `candidate` + `dca` rule per asset (never paper/live, never touching an existing rule), [E] edits weights, + [N] cancels. Off a terminal: prints the plan and writes nothing. + """ + try: + inputs = parse_plan_inputs(budget, buffer_pct, cadence_days) + except DcaPlanError as exc: + raise click.BadParameter(str(exc)) from exc + config = _load_cfg(ctx) + repo = _open_repo(ctx) + venue = _bound_venue_or_default(None) # after _load_cfg: rail 14's own venue binding + weights: dict[str, Decimal] | None = None + + while True: + plan = build_dca_plan( + repo, config, inputs, venue=venue, now_ts=int(time.time()), + screen_fn=screen_product, weights_override=weights, + ) + for line in render_dca_plan(plan): + click.echo(line) + if not _common._is_interactive(): + click.echo("") + click.echo("not a terminal: nothing written. Run this at a terminal to approve.") + if plan.blockers: + ctx.exit(1) + return + choices = (["Y"] if plan.approvable else []) + ["E", "N"] + prompt = " / ".join( + {"Y": "[Y] Approve", "E": "[E] Edit weights", "N": "[N] Cancel"}[c] for c in choices + ) + choice = click.prompt( + prompt, type=click.Choice(choices, case_sensitive=False), show_choices=False + ).upper() + if choice == "N": + click.echo("cancelled: nothing written.") + return + if choice == "E": + weights = _edit_weights(plan.editable) + continue + try: + apply_dca_plan( + repo, config, plan, now_ts=int(time.time()), + echo=click.echo, echo_err=lambda m: click.echo(m, err=True), + ) + except DcaPlanRefused as exc: + raise click.ClickException(str(exc)) from exc + return + + +def _edit_weights(editable: tuple[tuple[str, Decimal], ...]) -> dict[str, Decimal]: + """Prompt each editable asset's weight (default: its current one); re-prompt on bad input.""" + edited: dict[str, Decimal] = {} + for asset, current in editable: + while True: + raw = click.prompt(f"weight for {asset}", default=format(current, "f")) + try: + edited[asset] = parse_weight(raw, asset) + break + except DcaPlanError as exc: + click.echo(f" {exc}") + return edited +``` + +Add the import and registration to `keel/cli.py`. Update `SERVICE_MODULES` in `tests/commands/test_service_isolation.py`. + +When every weight is edited to 0, `select_universe` yields an empty universe. The "no asset is eligible" blocker then removes `[Y]`, so the loop offers `[E]/[N]` and nothing crashes. Add that case: + +```python +def test_editing_every_weight_to_zero_leaves_edit_or_cancel(deployment, monkeypatch) -> None: + monkeypatch.setattr(_common, "_is_interactive", lambda: True) + result = _run(deployment, "--budget", "500", "--buffer-pct", "0.1", input="E\n0\n0\n0\nN\n") + assert result.exit_code == 0, result.output + assert "no asset is eligible" in result.output + assert _repo(deployment[0]).get_rules() == [] +``` + +- [ ] **Step 4: Run the tests and confirm they pass, then run the whole suite** + + Run: `uv run pytest -q tests/commands/test_dca_cli.py tests/commands/test_service_isolation.py tests/test_cli.py` + + Expected: PASS. + + Then run the full suite: + + ``` + cd /Users/elmehdiaitbrahim/Development/work/CodeGate/keel-wt-dca && uv run pytest -q && uv run ruff check keel tests && uv run ruff format --check keel tests && uv run mypy + ``` + + Expected: all green. + + `ruff format` may rewrite `except (A, B):` tuples into the PEP 758 form. That is fine here. If a mutation run is repeated later, re-check that the mutant applied (memory: prove the mutant applied). + +- [ ] **Step 5: Commit, push and open PR 1** + +```bash +cd /Users/elmehdiaitbrahim/Development/work/CodeGate/keel-wt-dca && git add keel/commands/dca.py keel/cli.py tests/commands/test_dca_cli.py tests/commands/test_service_isolation.py && git commit -m "feat(dca): keel dca plan -- a thin CLI over the plan service, candidate-only on approval + +Co-Authored-By: Claude Opus 5.5 " +cd /Users/elmehdiaitbrahim/Development/work/CodeGate/keel-wt-dca && git push -u origin feat/dca-plan-service && gh pr create --base main --title "feat(dca): keel dca plan -- propose a multi-asset DCA schedule, write candidates on approval" --body "$(cat <<'EOF' +Implements PR 1 of docs/superpowers/plans/2026-09-27-dca-plan.md. + +- `keel/commands/dca_plan.py`: a pure service (no click) that returns a typed `DcaPlan`. +- `keel dca plan --budget --buffer-pct [--cadence-days]`: [Y]/[E]/[N] at a TTY; off a TTY it prints the plan and writes nothing. +- Approval writes one `candidate` `dca` rule per asset through `rules.add_rule_row`, all or nothing. It never promotes and never touches an existing rule. +- Rail 14 is read as the rail reads it (parity driven through `guards.check`) and stated as a monthly BUY cap, never fee-free (#836). +- The plan warns that the live executor sizes DCA from `config.dca.budget_usd` (executor.py:736-746). + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +--- + +# PR 2: read-only API and card (branch `feat/dca-plan-web`, stacked on PR 1) + +- [ ] Cut the stacked branch in a new worktree: + + ``` + cd /Users/elmehdiaitbrahim/Development/work/CodeGate/keel && git fetch -q && git worktree add -b feat/dca-plan-web ../keel-wt-dca-web origin/feat/dca-plan-service + ``` + + Work in `../keel-wt-dca-web` from here on. Before touching `payload.py` and `render.js`, read their module docstrings, `rules_payload`, `plans_payload`, `rulesView` and `plansView`. They state the rules the new code must keep. + +### Task 7: `payload.dca_plan_payload` and `GET /api/dca-plan` + +**Files:** +- Modify: `keel/web/payload.py`, adding `dca_plan_payload` and `dca_plan_awaiting_payload` after `rules_payload` (line ~3639), with `DcaPlan` imported under `TYPE_CHECKING`. +- Modify: `keel/web/api.py`, adding `read_dca_plan` after `read_rules` and an `API_ROUTES["/api/dca-plan"]` entry after `"/api/rules"`. +- Test: `tests/web/test_payload.py`, `tests/web/test_api.py`. In `test_api.py`, add `"/api/dca-plan"` to the module's `API_ROUTES` tuple. That one line enrols the route in the envelope, no-JSON-number, cache-header, nosniff and POST-is-404 pins. + +**Interfaces:** +- Consumes: + - `DcaPlan`, `PlannedBuy`, `ExcludedAsset`, `ExistingDcaRule`, `REASON_TEXT`, `RAIL14_NOTE`, `apply_command`, `parse_plan_inputs`, `build_dca_plan` and `DcaPlanError` from `keel.commands.dca_plan`. + - `payload.money`, `percent`, `label`, `count` and `absent`. + - `api._first`, `ApiRefusal`, `open_repo`, `close_repo`, `load_config` and `_bound_venue`. +- Produces: a wire shape that is **identical in both states**. The view-key scan in Task 8 reads it bare. + +``` +{ + "state": Field -- value "awaiting_budget" | "ready" | "blocked" + "command": str -- the exact CLI line (R17), or the template when awaiting + "summary": {"budget","buffer","spend","cap","planned","fees": Field} + "fee_rate": Field -- display "1.2% (configured fees.taker_pct)" + "cap_note": str -- RAIL14_NOTE + "buys": [{"asset": str, "product_id": str, "weight": Field, "cadence": Field, + "per_buy": Field, "monthly": Field, "fee": Field, "min_order": Field}] + "buy_count": Field + "excluded": [{"asset": str, "reasons": str, "detail": str}] + "existing": [{"rule_id": str, "product_id": str, "status": str, "per_buy": Field, "cadence": Field}] + "blockers": [str] + "warnings": [str] +} +``` + +- [ ] **Step 1: Write the failing tests** + +In `tests/web/test_payload.py`: + +```python +from keel.commands.dca_plan import RAIL14_NOTE, apply_command, build_dca_plan, parse_plan_inputs +from keel.web.payload import dca_plan_awaiting_payload, dca_plan_payload +from tests.commands.test_dca_plan import NOW_TS, _config, _repo, _screen +from tests.conftest import attest_subscription + +_DCA_KEYS = {"state", "command", "summary", "fee_rate", "cap_note", "buys", "buy_count", + "excluded", "existing", "blockers", "warnings"} + + +def _dca_plan(valid_config_path, cap="500"): + repo = _repo() + attest_subscription(repo, now_ts=NOW_TS, free_volume_usd=Decimal(cap)) + return build_dca_plan(repo, _config(valid_config_path), parse_plan_inputs("500", "0.1"), + venue="coinbase", now_ts=NOW_TS, screen_fn=_screen("PAXG")) + + +def test_the_dca_payload_has_one_shape_in_both_states(valid_config_path) -> None: + ready = dca_plan_payload(_dca_plan(valid_config_path), command="keel dca plan ...") + awaiting = dca_plan_awaiting_payload(command=apply_command(None)) + assert set(ready) == set(awaiting) == _DCA_KEYS + assert set(ready["summary"]) == set(awaiting["summary"]) + assert awaiting["state"]["value"] == "awaiting_budget" + assert awaiting["buys"] == [] and awaiting["summary"]["spend"]["state"] == "unknown" + + +def test_the_dca_payload_places_every_figure_the_plan_computed(valid_config_path) -> None: + plan = _dca_plan(valid_config_path) + body = dca_plan_payload(plan, command="keel dca plan --budget 500 --buffer-pct 0.1") + assert body["state"]["value"] == "ready" and body["state"]["state"] == "good" + assert body["buy_count"]["value"] == str(plan.buy_count) == "2" + assert [row["asset"] for row in body["buys"]] == [b.asset for b in plan.buys] + for row, buy in zip(body["buys"], plan.buys, strict=True): + assert row["per_buy"]["value"] == format(buy.per_buy_usd, "f") + assert row["monthly"]["value"] == format(buy.monthly_usd, "f") + assert row["weight"]["display"] == format(buy.weight_pct, "f") + "%" + assert row["cadence"]["display"] == "every 7 days" + assert row["min_order"]["state"] == "unknown" + assert body["summary"]["spend"]["value"] == "450.00" + assert body["fee_rate"]["display"] == "1.2% (configured fees.taker_pct)" + assert body["cap_note"] == RAIL14_NOTE + assert [row["asset"] for row in body["excluded"]] == ["PAXG"] + assert len(body["warnings"]) == len(plan.warnings) + + +def test_a_blocked_plan_says_so_in_state(valid_config_path) -> None: + body = dca_plan_payload(_dca_plan(valid_config_path, cap="100"), command="x") + assert body["state"]["value"] == "blocked" and body["state"]["state"] == "bad" + assert body["blockers"] + + +def test_no_dca_wire_value_is_a_json_number_and_none_says_fee_free(valid_config_path) -> None: + body = dca_plan_payload(_dca_plan(valid_config_path), command="x") + assert _json_numbers(body) == [] # this module's own walker + assert "fee-free" not in json.dumps(body).lower() +``` + +Use the file's existing number-walker helper. It is called `_json_numbers` in `test_api.py`; check its name in `test_payload.py` (`test_no_wire_value_is_ever_a_json_number`) and import or reuse that one. Do not write a new walker. + +In `tests/web/test_api.py`, add `"/api/dca-plan"` to the `API_ROUTES` tuple and add: + +```python +def test_dca_plan_without_a_budget_answers_the_awaiting_state(running) -> None: + status, _headers, document = _json(running, "/api/dca-plan") + assert status == 200 + assert document["data"]["state"]["value"] == "awaiting_budget" + assert document["data"]["command"].startswith("keel dca plan --budget ") + + +@pytest.mark.parametrize( + "query", ["budget=abc&buffer=0.1", "budget=500&buffer=10", "budget=500", "budget=NaN&buffer=0"] +) +def test_dca_plan_refuses_bad_input_with_a_400(running, query: str) -> None: + status, _headers, document = _json(running, "/api/dca-plan?" + query) + assert status == 400 + assert document["error"]["status"] == "400" + + +def test_dca_plan_with_a_budget_serves_the_services_plan(running, monkeypatch) -> None: + """Same service as the CLI: the reader calls `build_dca_plan` with THE screen, patched here + at its module so the route sees an admitted allowlist.""" + from keel.commands import assets + from tests.commands.test_dca_plan import _screen + + monkeypatch.setattr(assets, "screen_product", _screen()) + status, _headers, document = _json(running, "/api/dca-plan?budget=1e3&buffer=0.1") + assert status == 200 + data = document["data"] + assert data["summary"]["spend"]["value"] == "900.00" + assert [row["asset"] for row in data["buys"]] == ["BTC", "ETH", "PAXG"] + # R17 + Review Focus 4: the exact command, no exponent, naming the served deployment. + assert data["command"].endswith("dca plan --budget 1000 --buffer-pct 0.1") + assert "--config" in data["command"] and "--db" in data["command"] + + +def test_dca_plan_writes_nothing(running, monkeypatch) -> None: + from keel.commands import assets + from tests.commands.test_dca_plan import _screen + + monkeypatch.setattr(assets, "screen_product", _screen()) + _json(running, "/api/dca-plan?budget=500&buffer=0.1") + conn = sqlite3.connect(running.db_path) + try: + assert conn.execute("SELECT COUNT(*) FROM rules").fetchone()[0] == 0 + finally: + conn.close() +``` + +`test_dca_plan_with_a_budget_serves_the_services_plan` only proves anything if the patch reaches the reader. The reader must import `screen_product` inside the function (`from keel.commands.assets import screen_product`), so it resolves the patched attribute at call time. Step 4 verifies this. + +- [ ] **Step 2: Run the tests and confirm they fail** + + Run: `uv run pytest -q tests/web/test_payload.py tests/web/test_api.py -k "dca or every_route or this_module_pins"` + + Expected: FAIL. The imports fail, and `test_this_module_pins_every_route_the_server_serves` fails because the tuple names a route the server lacks. + +- [ ] **Step 3: Write the minimal implementation** + +In `keel/web/payload.py`, add `from keel.commands.dca_plan import DcaPlan` inside the `TYPE_CHECKING` block, then: + +```python +# -- dca plan (read-only proposal card on /rules) --------------------------------------------- + + +def _dca_summary(plan: DcaPlan | None) -> dict[str, Field]: + if plan is None: + return {key: absent() for key in ("budget", "buffer", "spend", "cap", "planned", "fees")} + cap = plan.cap.allowance_usd + return { + "budget": money(plan.inputs.budget_usd), + "buffer": money(plan.buffer_usd), + "spend": money(plan.spend_usd), + "cap": ( + label("unlimited", state=NEUTRAL) + if cap is None + else money(cap, state=GOOD if plan.cap.in_force else BAD) + ), + "planned": money(plan.planned_monthly_usd), + "fees": money(plan.est_monthly_fees_usd), + } + + +def dca_plan_awaiting_payload(*, command: str) -> dict[str, Any]: + """No budget asked for yet: the SAME keys as `dca_plan_payload`, every figure `absent()`, and + the command template -- a client reads one shape and never branches on it (Rule 3).""" + from keel.commands.dca_plan import RAIL14_NOTE + + return { + "state": label( + "awaiting_budget", + display="add ?budget=&buffer= to this page's address", + state=UNKNOWN, + ), + "command": command, + "summary": _dca_summary(None), + "fee_rate": absent(), + "cap_note": RAIL14_NOTE, + "buys": [], + "buy_count": count(0), + "excluded": [], + "existing": [], + "blockers": [], + "warnings": [], + } + + +def dca_plan_payload(plan: DcaPlan, *, command: str) -> dict[str, Any]: + """`keel.commands.dca_plan.DcaPlan`, as JSON. Every figure was computed by the service -- + `weight_pct`, `taker_pct_display` and `buy_count` exist so nothing here multiplies or counts + (Rules 2 and 6). READ-ONLY: the card carries the CLI command, and nothing on this route can + write (`api.py`'s header: not one route answers a POST).""" + from keel.commands.dca_plan import RAIL14_NOTE, REASON_TEXT + + return { + "state": ( + label("ready", display="ready to apply from a terminal", state=GOOD) + if plan.approvable + else label("blocked", display="cannot be approved as it stands", state=BAD) + ), + "command": command, + "summary": _dca_summary(plan), + "fee_rate": label( + format(plan.taker_pct, "f"), + display=_trim(format(plan.taker_pct_display, "f")) + "% (configured fees.taker_pct)", + ), + "cap_note": RAIL14_NOTE, + "buys": [ + { + "asset": buy.asset, + "product_id": buy.product_id, + "weight": percent(buy.weight_pct, places=1), + "cadence": label(str(buy.cadence_days), display=f"every {buy.cadence_days} days"), + "per_buy": money(buy.per_buy_usd), + "monthly": money(buy.monthly_usd), + "fee": money(buy.est_monthly_fee_usd), + "min_order": ( + label("unknown", display="unknown to keel", state=UNKNOWN) + if buy.min_order_usd is None + else money(buy.min_order_usd) + ), + } + for buy in plan.buys + ], + "buy_count": count(plan.buy_count), + "excluded": [ + { + "asset": item.asset, + "reasons": ", ".join(REASON_TEXT[r] for r in item.reasons), + "detail": item.detail, + } + for item in plan.excluded + ], + "existing": [ + { + "rule_id": str(rule.rule_id), + "product_id": rule.product_id, + "status": rule.status, + "per_buy": money(rule.budget_usd), + "cadence": label(str(rule.cadence_days), display=f"every {rule.cadence_days} days"), + } + for rule in plan.existing + ], + "blockers": list(plan.blockers), + "warnings": list(plan.warnings), + } +``` + +Check `_trim`'s behaviour before relying on it: it may not trim `1.200` to `1.2`. If it does not, add `taker_pct_display_text: str` to `DcaPlan` in the service, computed with `_trim_pct` from Task 4, and place it here. The trim must not be re-implemented in the payload. + +`percent(buy.weight_pct, places=1)` renders `40.0%`, which matches `format(Decimal("40.0"), "f") + "%"`. + +In `keel/web/api.py`, add after `read_rules`: + +```python +def read_dca_plan(cfg: ServeConfig, query: Query, _state: Any, now_ts: int) -> dict[str, Any]: + """The DCA proposal card on `/rules`, from THE service the CLI calls + (`keel.commands.dca_plan.build_dca_plan`). READ-ONLY: it builds a plan and writes nothing; + applying it is `keel dca plan` at a terminal, and the payload carries that exact command. + + No `budget` -> the awaiting shape (200: `test_every_route_answers_json_with_the_envelope` + requests every route bare). A malformed budget/buffer, or a budget without a buffer, is a + 400 -- refused, never guessed, as `_sort_request` refuses an unknown column.""" + from keel.commands.assets import screen_product # resolved per call: one patch point + from keel.commands.dca_plan import DcaPlanError, apply_command, build_dca_plan, parse_plan_inputs + + budget, buffer = _first(query, "budget"), _first(query, "buffer") + if not budget: + return payload.dca_plan_awaiting_payload(command=apply_command(None)) + if not buffer: + raise ApiRefusal(400, "Bad plan input", "buffer is required with budget, e.g. buffer=0.1.") + try: + inputs = parse_plan_inputs(budget, buffer) + except DcaPlanError as exc: + raise ApiRefusal(400, "Bad plan input", str(exc)) from exc + config = load_config(cfg.config_path) + repo = open_repo(cfg.db_path) + try: + plan = build_dca_plan( + repo, config, inputs, venue=_bound_venue(cfg), now_ts=now_ts, screen_fn=screen_product + ) + finally: + close_repo(repo) + return payload.dca_plan_payload( + plan, command=apply_command(inputs, config_path=cfg.config_path, db_path=cfg.db_path) + ) +``` + +Then add the route entry after `"/api/rules"`: + +```python + # A read-only proposal: GET only, no collection to sort. The card on /rules places it. + "/api/dca-plan": ApiRoute(html_route="/rules", read=read_dca_plan), +``` + +`read_dca_plan` reads `now_ts` from its argument and never calls `time.time()`, which `test_no_reader_reads_the_clock_a_second_time` requires. It also uses no `format(`, which `test_the_routing_layer_formats_nothing` requires: every string is built by the service or the payload. + +- [ ] **Step 4: Run the tests and confirm they pass, then prove the screen patch reaches the reader** + + Run: `uv run pytest -q tests/web tests/commands/test_console_thinness.py` + + Expected: PASS. + + Mutation check. Change the reader's import to a module-level `from keel.commands.assets import screen_product` placed at the top of `api.py`. Confirm the source changed with `grep -n "^from keel.commands.assets" keel/web/api.py`. Run `test_dca_plan_with_a_budget_serves_the_services_plan`. Expected: FAIL, because nothing is admitted without the patch. Restore from a saved copy. + + Then run the full suite: `uv run pytest -q && uv run ruff check keel tests && uv run ruff format --check keel tests && uv run mypy`. + +- [ ] **Step 5: Commit** + +```bash +cd /Users/elmehdiaitbrahim/Development/work/CodeGate/keel-wt-dca-web && git add keel/web/payload.py keel/web/api.py tests/web/test_payload.py tests/web/test_api.py && git commit -m "feat(web): GET /api/dca-plan -- the DCA proposal from the same service, read-only + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 8: The read-only card on `/rules` (`render.js`, `main.js`), with structural client tests and the SW pin + +**Files:** +- Modify: `keel/web/static/js/render.js`, adding `export function dcaPlanCard(plan)`, and `rulesView(data, sort, onSort, plan)` appends it. +- Modify: `keel/web/static/js/main.js`, setting the rules route to `endpoints: ["rules", "dca-plan"]`, adding page-query seeding, and passing `readings[1]`. +- Test: `tests/web/test_client_assets.py`, `tests/web/test_pwa.py`. + +**Interfaces:** +- Consumes: the Task 7 wire shape. +- Produces: `dcaPlanCard(plan)` returns an `HTMLElement`, a `
`. It contains no `button`, `a`, `form` or `input`, and no event handlers. + +- [ ] **Step 1: Write the failing tests** + +In `tests/web/test_client_assets.py`, add this row to `_VIEW_ENDPOINTS`: + +```python + # The DCA proposal card (PR 2 of the dca-plan plan). Read bare, the endpoint answers its + # awaiting shape, which carries every key the ready shape does. + ("dcaPlanCard", "plan", "/api/dca-plan"), +``` + +Add the structural tests. They count tags and pair them with keys, and never rely on a substring match alone: + +```python +import re as _re + +#: The only element tags the card may build. `code` carries the CLI command; nothing on the +#: list can take an action. +_DCA_CARD_TAGS = {"section", "h2", "h3", "p", "code", "ul", "li", "span", "div"} + + +def _card_bodies() -> str: + source = _source("render.js") + return "\n".join( + _function_body(source, name) + for name in ("dcaPlanCard", "dcaPlanFigure", "dcaPlanRows", "dcaPlanList") + ) + + +def test_the_dca_card_builds_only_non_interactive_elements() -> None: + """Structure, not substrings: every `el(""` the card makes is on a closed list, and + the list holds nothing a reader can activate. `table()` is used without a sort pair.""" + body = _card_bodies() + tags = _re.findall(r'\bel\("([a-z0-9]+)"', body) + assert tags, "the scan found no elements -- it would pass against any card" + assert set(tags) <= _DCA_CARD_TAGS, sorted(set(tags) - _DCA_CARD_TAGS) + tables = _re.findall(r"\btable\(", body) + assert tables, "the card places its rows through table()" + assert "onSort" not in body and "sort:" not in body + for forbidden in _INTERACTIVE_TOKENS: + assert forbidden not in body, f"the DCA card builds something interactive: {forbidden}" + + +def test_the_dca_card_places_the_command_in_exactly_one_code_element() -> None: + """The pairing: one `code` element, and it is filled from `plan.command`.""" + body = _card_bodies() + code_calls = _re.findall(r'\bel\("code"[^)]*\)', body) + assert len(code_calls) == 1, code_calls + assert "plan.command" in code_calls[0] + + +def test_the_dca_card_renders_every_section_the_payload_sends(running) -> None: # type: ignore[no-untyped-def] + """Derived from the served payload, not a list of literals: a key added to the wire and + never placed fails here.""" + status, _headers, body = _request(running, "/api/dca-plan", cookie=_session(running)) + assert status == 200 + keys = set(json.loads(body)["data"]) + assert keys, "the endpoint sent nothing -- this would pass against any card" + card = _card_bodies() + for key in sorted(keys): + assert "plan." + key in card, f"/api/dca-plan sends {key}; the card never places it" + + +def test_the_rules_view_reads_the_dca_plan_as_its_second_endpoint() -> None: + source = _source("main.js") + start = source.index("const ROUTES = [") + table_src = source[start : source.index("];", start)] + assert '{ name: "rules", label: "Rules", endpoints: ["rules", "dca-plan"] }' in table_src +``` + +The last test is a pinned table-row literal. It checks one declaration, which is precisely the table row `_js_route_names` parses. Its pairing partner is `test_the_python_and_javascript_route_tables_agree`. + +Add to `tests/web/test_pwa.py`: + +```python +def test_every_api_route_sits_under_the_prefix_the_worker_never_caches() -> None: + """The worker declines `/api/` wholesale; that only protects a route that lives under it.""" + from keel.web import api as web_api + + source = (_STATIC / "sw.js").read_text(encoding="utf-8") + prefix = re.search(r'const API_PREFIX = "([^"]+)"', source) + assert prefix is not None + assert "/api/dca-plan" in web_api.API_ROUTES # the population includes the new route + assert all(path.startswith(prefix.group(1)) for path in web_api.API_ROUTES) +``` + +Use the static-dir constant `test_pwa.py` already defines. Read the file's existing `test_the_api_prefix_is_now_inside_the_workers_scope` and reuse its path helper rather than adding `_STATIC`. + +- [ ] **Step 2: Run the tests and confirm they fail** + + Run: `uv run pytest -q tests/web/test_client_assets.py tests/web/test_pwa.py -k "dca or view_reads_only or rules_view or prefix_the_worker"` + + Expected: FAIL with `render.js declares no top-level function dcaPlanCard`. + +- [ ] **Step 3: Write the minimal implementation** + +The `render.js` constraints come from its header. The file must contain: +- no template literals or regex literals; +- no `+ - * / %` arithmetic; +- no `Number`, `parseInt`, `Math` or `toFixed`; +- no read of `.value`; +- no relational `<` or `>`. + +Build strings with `.concat` or `[...].join`. No string in the card may contain `form`, `input`, `button` or `href`, not even inside words such as "information" or "format", because `_INTERACTIVE_TOKENS` keeps string literals. + +```js +/** + * The DCA proposal (`/api/dca-plan`), as a card under the rules table. + * + * **Read-only, and structurally so.** There is no control on it: it builds no button, anchor or + * input, and `table()` is called without a sort pair. The only way to apply the plan is the CLI + * command it shows, which runs at a terminal where `[Y]` writes `candidate` rules. The browser + * performs no capability increase (`keel/capabilities.py`). + * + * Its inputs come from this page's own address (`/rules?budget=500&buffer=0.1`). `main.js` + * copies them into the endpoint's query. Without them the payload's `awaiting_budget` state + * says what to add. + * + * @param {any} plan `/api/dca-plan`'s `data`, or `null` when that read failed. + * @returns {HTMLElement} + */ +export function dcaPlanCard(plan) { + const card = el("section", "card"); + card.append(heading("h-dca-plan", "DCA plan proposal")); + if (!plan) { + card.append(note("The DCA proposal could not be read.")); + return card; + } + const stateLine = el("p"); + stateLine.append(field(plan.state)); + card.append(stateLine); + card.append(note(plain(plan.cap_note))); + card.append( + dcaPlanFigure("spend / month", plan.summary.spend), + dcaPlanFigure("budget", plan.summary.budget), + dcaPlanFigure("held back", plan.summary.buffer), + dcaPlanFigure("rail 14 monthly buy cap", plan.summary.cap), + dcaPlanFigure("planned / month", plan.summary.planned), + dcaPlanFigure("est. fees / month", plan.summary.fees), + dcaPlanFigure("fee rate", plan.fee_rate), + dcaPlanFigure("assets", plan.buy_count), + ); + card.append(dcaPlanRows(plan)); + card.append(dcaPlanList("h-dca-excluded", "Excluded", plan.excluded, "Nothing excluded.")); + card.append(dcaPlanList("h-dca-existing", "Existing DCA rules (unchanged)", plan.existing, "None.")); + card.append(dcaPlanList("h-dca-blockers", "Cannot approve", plan.blockers, "Nothing blocks it.")); + card.append(dcaPlanList("h-dca-warnings", "Notes", plan.warnings, "None.")); + card.append(el("h3", undefined, "To apply, at a terminal:")); + card.append(el("code", "muted", plain(plan.command))); + return card; +} + +/** + * One label and one `Field`. Deliberately NOT `kv()`: `kv` turns a label naming a documented + * term into an outbound link (#539), and this card builds no anchor at all. + * + * @param {string} label @param {any} value @returns {HTMLElement} + */ +function dcaPlanFigure(label, value) { + const wrap = el("div", "kv"); + wrap.append(el("span", "muted", label), field(value)); + return wrap; +} + +/** The per-asset schedule. @param {any} plan @returns {HTMLElement} */ +function dcaPlanRows(plan) { + return table( + "h-dca-plan", + [ + { label: "asset", numeric: false }, + { label: "weight", numeric: true }, + { label: "cadence", numeric: false }, + { label: "per buy", numeric: true }, + { label: "monthly", numeric: true }, + { label: "est. fee", numeric: true }, + { label: "venue minimum", numeric: false }, + ], + (plan.buys || []).map( + /** @param {any} row */ (row) => [ + plain(row.asset), + row.weight, + row.cadence, + row.per_buy, + row.monthly, + row.fee, + row.min_order, + ], + ), + "No asset is eligible.", + ); +} + +/** + * A heading and a bullet list of strings or small records -- the card's four lists. + * + * @param {string} id @param {string} title @param {any[]} items @param {string} empty + * @returns {HTMLElement} + */ +function dcaPlanList(id, title, items, empty) { + const wrap = el("div"); + wrap.append(el("h3", undefined, title)); + const rows = items || []; + if (rows.length === 0) { + wrap.append(el("p", "empty", empty)); + return wrap; + } + const list = el("ul"); + for (const item of rows) { + list.append( + el( + "li", + undefined, + typeof item === "string" + ? item + : [plain(item.rule_id), plain(item.asset), plain(item.product_id), plain(item.status), + plain(item.reasons), plain(item.detail)].filter((part) => part !== "").join(" · "), + ), + ); + } + wrap.append(list); + return wrap; +} +``` + +The `existing` rows carry `per_buy` and `cadence` fields. The list shows id, product and status. For the section-coverage test, `plan.existing` is referenced, which is enough. Keep the list simple rather than adding a second table. + +Change `rulesView`'s signature to `rulesView(data, sort, onSort, plan)` and add the parameter to its JSDoc. Before `return fragment;`, add: + +```js + fragment.append(dcaPlanCard(plan || null)); +``` + +`||` is neither arithmetic nor relational, so the scanner allows it. + +In `main.js`: + +- In the ROUTES table, set `{ name: "rules", label: "Rules", endpoints: ["rules", "dca-plan"] },`. +- After `paramsFor` is defined, seed the endpoint's query once from the page address (R16): + +```js +// The DCA card's inputs come from this page's own address (`/rules?budget=500&buffer=0.1`): +// the card is read-only and has no form, so the URL is the one input it takes. Copied once, into +// that endpoint's query bag only -- the rules table's own `?sort=` stays out of the URL, per +// `params`' note. +{ + const page = new URLSearchParams(window.location.search); + for (const name of ["budget", "buffer"]) { + const value = page.get(name); + if (value !== null) paramsFor("dca-plan")[name] = value; + } +} +``` + +- In `mount`, set `if (route.name === "rules") return rulesView(data, primary.sort, onSort, readings[1] ? readings[1].data : null);`. + +- [ ] **Step 4: Run the tests and confirm they pass** + + Run: `uv run pytest -q tests/web` + + Expected: PASS. + + Pay particular attention to these existing tests: + - `test_render_contains_no_arithmetic` + - `test_render_never_judges_a_value_itself` + - `test_render_uses_neither_template_nor_regex_literals` + - `test_every_table_emits_one_cell_per_declared_header` (7 headers, 7 cells) + - `test_every_call_resolves_to_something_the_module_has` + - `test_no_module_declares_a_name_twice_in_one_scope` + - `test_every_row_key_a_view_reads_is_a_key_its_endpoint_sends` + + The last one may need a row-key mapping for `/api/dca-plan` `buys`. Read `test_every_mapped_collection_is_either_checked_or_named`. If it asks for the new collection to be named, add it to the table there with `buys` as the collection. Do not add it to an exemption list. + + Then check the card past the service worker (memory: verify the web UI past the service worker). Run `uv run keel serve` against a scratch deployment, unregister the SW in the browser (or use a fresh port), and open `/rules?budget=500&buffer=0.1`. Confirm that the card renders, that it has no control on it, and that the command line matches the CLI. + + Then run the full suite: `uv run pytest -q && uv run ruff check keel tests && uv run ruff format --check keel tests && uv run mypy`. + +- [ ] **Step 5: Commit, push and open PR 2** + +```bash +cd /Users/elmehdiaitbrahim/Development/work/CodeGate/keel-wt-dca-web && git add keel/web/static/js/render.js keel/web/static/js/main.js tests/web/test_client_assets.py tests/web/test_pwa.py && git commit -m "feat(web): read-only DCA proposal card on /rules with the exact CLI command + +Co-Authored-By: Claude Opus 5.5 " +cd /Users/elmehdiaitbrahim/Development/work/CodeGate/keel-wt-dca-web && git push -u origin feat/dca-plan-web && gh pr create --base feat/dca-plan-service --title "feat(web): read-only DCA proposal card on /rules" --body "$(cat <<'EOF' +PR 2 of docs/superpowers/plans/2026-09-27-dca-plan.md. It is stacked on the service PR; retarget to main after that merges. + +- `GET /api/dca-plan?budget=&buffer=` returns the plan from the same service as `keel dca plan`. Money is sent as strings and every value has an explicit state. Without a budget it answers an `awaiting_budget` state with the same shape. +- The `/rules` card shows the per-asset allocation and the exact CLI command. It has no button, form or apply verb (pinned structurally). +- The service worker's `/api/` bypass is pinned to cover every route. + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +--- + +## Self-Review + +**1. Spec coverage** + +| Requirement | Where it is covered | +|---|---| +| The command and its flags | Tasks 1 and 6 | +| Universe = allowlist ∩ admitted ∩ positive weight, renormalised | Task 2 | +| Excluded assets listed with their reasons | Tasks 2 and 4; R4 adds `not_on_allowlist` | +| Spend = budget × (1 − buffer) and must fit rail 14's cap as the rail reads it | Tasks 1 and 3; parity through `guards.check` | +| Rail 14 is a buy cap, never "fee-free" | Task 4, plus `test_rail14_is_a_buy_cap.py` and the Task 7 payload test | +| Per-asset `Dca` rules, `cadence_days=7`, per-buy formula | Tasks 3 and 5 | +| Cadence, per-buy, monthly and total shown | Task 4 | +| Fees at the configured `taker_pct`, labelled as such | Tasks 3, 4 and 7 | +| Venue minimum order size, or "unknown" | Task 3 (`MIN_ORDER_UNKNOWN`), Task 7 (`min_order`) | +| `[Y]/[E]/[N]` at a TTY | Task 6 | +| `[E]` edits, renormalises and re-shows | Tasks 2 and 6 | +| `[Y]` writes candidates only, through the `rules add` path | Task 5 | +| Never promote, never touch paper/live or existing rules | Task 5 (`test_approval_never_touches_an_existing_rule`) | +| Off a TTY, prints and writes nothing | Task 6 | +| An existing DCA rule means "existing, unchanged" | Tasks 2 and 4 | +| Pure service plus thin CLI; no `keel.cli` import | Tasks 1 and 6 (R13 test, `SERVICE_MODULES`) | +| GET-only route, payload wire rules | Task 7 (API_ROUTES tuple enrolment gives POST 404, no JSON numbers, no-store) | +| Card with allocation and exact command | Task 8 | +| No write verb, no button, no form | Task 8 structural tests; `test_the_handler_still_declares_exactly_three_verbs` untouched | +| Client tests assert structure | Task 8: tag whitelist, one `code` paired with `plan.command`, and payload-derived section coverage | +| Service worker never caches `/api/` | Task 8 `test_pwa` | +| TDD, commands and worktree | Every task | + +No gaps. + +**2. Placeholder scan.** There is no "TBD", "add validation" or "similar to Task N". Every code step carries code. Four spots tell the implementer to check a real name before relying on it: `guards._asset`'s casing, `payload._trim`'s behaviour, the number-walker helper's name in `test_payload.py`, and `test_pwa.py`'s static-path helper. Each gives the fallback to use if the name differs. + +**3. Type consistency.** These names are used identically across Tasks 1 to 8: +- `PlanInputs(budget_usd, buffer_pct, cadence_days)` +- `BuyCap(venue, allowance_usd, degraded_reason, pacing).in_force` +- `select_universe(...) -> Universe(allocations, excluded, existing, editable)` +- `build_dca_plan(repo, config, inputs, *, venue, now_ts, screen_fn, weights_override=None) -> DcaPlan` +- `DcaPlan.{buys, buy_count, blockers, warnings, approvable, taker_pct_display, editable, cap}` +- `render_dca_plan(plan) -> list[str]` +- `apply_dca_plan(repo, config, plan, *, now_ts, echo, echo_err) -> tuple[RulesOutcome, ...]` +- `apply_command(inputs | None, *, config_path, db_path)` +- `dca_plan_payload(plan, *, command)` / `dca_plan_awaiting_payload(*, command)` +- `dcaPlanCard(plan)` + +**4. Review Focus.** Each of the five lines has its pinning test in the owning task: +- Rounds to $0.00: Task 3, `test_a_per_buy_that_rounds_to_zero_is_a_named_blocker`. +- Unattested cap: Task 1 parity and Task 3 `test_an_unattested_venue_blocks_with_the_attest_command`. +- Concurrent rule: Task 5, `test_a_dca_rule_written_after_the_preview_refuses_the_whole_approval`. +- Percent-shaped and exotic input: Task 1 parametrised refusals and `test_apply_command_never_spells_an_exponent`, plus the Task 7 `budget=1e3` command check. +- Casing and off-allowlist weights: Task 2, two tests. + +**"Done" criteria:** +- Both PRs are green on `uv run pytest -q`, both ruff commands and bare `uv run mypy`. +- No existing security pin is weakened: `test_service_isolation` gains two modules, `test_api`'s route tuple gains one row, and `_VIEW_ENDPOINTS` gains one row. +- No exemption was added to any scan. +- `guards.py` and `executor.py` are untouched. diff --git a/keel/cli.py b/keel/cli.py index 76ace67b..6f5b81ad 100644 --- a/keel/cli.py +++ b/keel/cli.py @@ -148,6 +148,7 @@ ) from keel.commands.credentials import credentials_group from keel.commands.db import db_group +from keel.commands.dca import dca_group from keel.commands.doctor import doctor_cmd from keel.commands.fetch import assess_products as _assess_products # noqa: F401 -- pinned by tests @@ -1212,6 +1213,14 @@ def stop_flag(_count: list[int] = [0]) -> bool: # noqa: B006 - intentional muta cli.add_command(rules_group) +# -- dca ------------------------------------------------------------------------------ + +# The `dca` group is defined in `keel.commands.dca`: a thin CLI over `keel.commands.dca_plan` +# (the pure service) that proposes a multi-asset DCA schedule and, on approval at a terminal, +# writes one `candidate` `dca` rule per asset through `rules.add_rule_row`. +cli.add_command(dca_group) + + # -- research (the front door over keel/research/*, issue #601) ----------------------------- # The `research` group is defined in `keel.commands.research`: an index over all thirteen diff --git a/keel/commands/dca.py b/keel/commands/dca.py new file mode 100644 index 00000000..310fe6c6 --- /dev/null +++ b/keel/commands/dca.py @@ -0,0 +1,163 @@ +"""`keel dca` -- the CLI front-end over `keel.commands.dca_plan` (the service). + +Thin on purpose: parse options, run the `[Y]/[E]/[N]` loop, echo the service's lines. Every +figure, refusal and write lives in the service, which `keel/web/api.py` calls too. + +**The terminal is load-bearing, the heavier gate is not** -- `keel/commands/journal.py`'s +reasoning. Writing `candidate` rules is what `keel rules add` does ungated: a candidate cannot +trade until `keel rules promote` clears it, so `_require_interactive_confirmation`'s typed `yes` +would be ceremony. But a prompt needs a human, so off a TTY this prints and writes nothing. + +**Interactivity is decided FIRST, and once, and the database is opened accordingly** (coordinator +ruling R20). `_common._is_interactive()` is read exactly once, before anything touches the +database, and that one value drives both which opener runs and the rest of the command's +behaviour. Off a TTY the opener is `_common._open_repo_ro` -- a read-only `mode=ro` connection +that REFUSES a missing `--db` path or a stale schema rather than creating or migrating the file -- +because either of those, under a plain read-write connection, would itself be a write. At a TTY +the opener is `_common._open_repo`, the ordinary read-write one `[Y]` needs to insert rows. + +Both openers are reached as `_common.(ctx)` -- module attribute access, never a name bound +by `from ... import ...` -- for the same reason `_common`'s own docstring gives for +`_is_interactive`: it is the one patch point a test can rebind no matter which module the calling +command lives in. +""" + +from __future__ import annotations + +import time +from decimal import Decimal + +import click + +from keel.commands import _common +from keel.commands._common import _bound_venue_or_default, _load_cfg, with_disclaimer +from keel.commands.assets import screen_product +from keel.commands.dca_plan import ( + DEFAULT_CADENCE_DAYS, + DcaPlanError, + DcaPlanRefused, + apply_dca_plan, + build_dca_plan, + parse_plan_inputs, + parse_weight, + render_dca_plan, +) + +#: The `[Y]/[E]/[N]` prompt's per-choice label, keyed by letter. One table, so the loop's prompt +#: line and its retry message cannot disagree about what each letter means. +_CHOICE_LABELS = {"Y": "[Y] Approve", "E": "[E] Edit weights", "N": "[N] Cancel"} + + +@click.group("dca") +def dca_group() -> None: + """Plan a multi-asset DCA schedule (writes `candidate` rules only, on approval).""" + + +@dca_group.command("plan") +@click.option("--budget", required=True, help="Monthly budget in USD, e.g. 500.") +@click.option( + "--buffer-pct", + required=True, + help="Fraction of the budget held back, in [0, 1): 0.1 holds back 10%.", +) +@click.option( + "--cadence-days", + type=int, + default=DEFAULT_CADENCE_DAYS, + show_default=True, + help="Days between buys for every asset in the plan.", +) +@click.pass_context +@with_disclaimer +def dca_plan_cmd(ctx: click.Context, budget: str, buffer_pct: str, cadence_days: int) -> None: + """Propose a DCA schedule over the admitted, weighted allowlist. + + Spend is budget x (1 - buffer-pct), and must fit rail 14's monthly BUY cap (a cap keel + imposes on its own buying -- not a fee waiver). At a terminal: [Y] writes one `candidate` + `dca` rule per asset (never paper/live, never touching an existing rule), [E] edits weights, + [N] cancels. Off a terminal: prints the plan and writes nothing. + """ + try: + inputs = parse_plan_inputs(budget, buffer_pct, cadence_days) + except DcaPlanError as exc: + raise click.BadParameter(str(exc)) from exc + + # R20: decide interactivity FIRST, once, before the database is touched at all. + interactive = _common._is_interactive() + config = _load_cfg(ctx) + repo = _common._open_repo(ctx) if interactive else _common._open_repo_ro(ctx) + venue = _bound_venue_or_default(None) # after _load_cfg: rail 14's own venue binding + weights: dict[str, Decimal] | None = None + + while True: + try: + plan = build_dca_plan( + repo, + config, + inputs, + venue=venue, + now_ts=int(time.time()), + screen_fn=screen_product, + weights_override=weights, + ) + except DcaPlanError as exc: + # `DcaPlanError`'s own docstring: "the message is shown verbatim" -- a config-level + # refusal (case-colliding `target_weights`, #848) or a stray `[E]` edit must reach + # the operator as a clean error, not an uncaught traceback out of the CLI. + raise click.ClickException(str(exc)) from exc + for line in render_dca_plan(plan): + click.echo(line) + if not interactive: + click.echo("") + click.echo("not a terminal: nothing written. Run this at a terminal to approve.") + if plan.blockers: + ctx.exit(1) + return + choice = _prompt_choice(plan.approvable) + if choice == "N": + click.echo("cancelled: nothing written.") + return + if choice == "E": + weights = _edit_weights(plan.editable) + continue + try: + apply_dca_plan( + repo, + config, + plan, + now_ts=int(time.time()), + echo=click.echo, + echo_err=lambda m: click.echo(m, err=True), + ) + except DcaPlanRefused as exc: + raise click.ClickException(str(exc)) from exc + return + + +def _prompt_choice(approvable: bool) -> str: + """Show the offered letters ONCE, then read raw input in its own loop -- deliberately not + `click.prompt(..., type=click.Choice(...))`, whose built-in retry re-displays the full prompt + text on every invalid entry. A blocked plan's `[E] Edit weights / [N] Cancel` line must appear + exactly once even if the operator types the `[Y]` that was not offered.""" + choices = (["Y"] if approvable else []) + ["E", "N"] + click.echo(" / ".join(_CHOICE_LABELS[c] for c in choices)) + while True: + raw = click.prompt("choice", prompt_suffix="> ", default="", show_default=False) + answer = raw.strip().upper() + if answer in choices: + return answer + click.echo(f" please answer one of: {', '.join(choices)}") + + +def _edit_weights(editable: tuple[tuple[str, Decimal], ...]) -> dict[str, Decimal]: + """Prompt each editable asset's weight (default: its current one); re-prompt on bad input.""" + edited: dict[str, Decimal] = {} + for asset, current in editable: + while True: + raw = click.prompt(f"weight for {asset}", default=format(current, "f")) + try: + edited[asset] = parse_weight(raw, asset) + break + except DcaPlanError as exc: + click.echo(f" {exc}") + return edited diff --git a/keel/commands/dca_plan.py b/keel/commands/dca_plan.py new file mode 100644 index 00000000..5fd33c4d --- /dev/null +++ b/keel/commands/dca_plan.py @@ -0,0 +1,782 @@ +"""`keel dca plan` -- a multi-asset DCA schedule, proposed from this deployment's configuration and +written only on approval, only at `candidate`, through `keel rules add`'s own service. + +**A pure service: no click here** (the same split `keel/commands/rules.py` documents, one step +further -- the web console imports this module, and click is a front-end's). Two front-ends: +`keel/commands/dca.py` (the CLI, which owns the `[Y]/[E]/[N]` loop) and `keel/web/api.py` +(`/api/dca-plan`, read-only). Both call `build_dca_plan`; only the CLI can reach +`apply_dca_plan`, and only from a terminal. + +**Rail 14 is a monthly BUY cap, not a fee waiver (#836).** keel trades on Coinbase Advanced +Trade, where every order pays the venue's fee. `monthly_buy_cap` reads the attested record +exactly as `keel/execution/guards.py` rail 14 does; `tests/commands/test_dca_plan.py` drives +`guards.check` itself to prove the two agree. + +**What this module never does:** promote, touch a `paper`/`live` row, or modify any existing rule. +It writes `candidate` rows through `keel.commands.rules.add_rule_row` and nothing else. +""" + +from __future__ import annotations + +import inspect +import json +import shlex +import sqlite3 +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from decimal import ROUND_DOWN, ROUND_UP, Decimal, InvalidOperation +from typing import Literal + +from keel_core.subscription import SubscriptionStatus + +from keel import agent +from keel.commands._products import _history_product, parse_products_option +from keel.commands.admission import ScreenFn, build_screen_report +from keel.commands.rules import RulesOutcome, RulesRefused, RulesUsageError, add_rule_row +from keel.config import Config +from keel.data.repository import Repository + +# Rail 1's own key function, as `keel/commands/rules.py` imports it: an existing rule's asset is +# read the way the rails read it, so "already has a DCA rule" cannot disagree with them. +from keel.execution.guards import _asset as _asset_of +from keel.strategy.rules.dca import Dca + +#: The default `Dca.__init__` itself uses for `budget_usd`, read from its signature -- not a +#: re-typed literal, and without touching the rule class. A stored row missing `budget_usd` +#: (`existing_dca_rules`) is built by `agent.build_rule_from_params` with exactly this default. +DCA_DEFAULT_BUDGET_USD = Decimal(str(inspect.signature(Dca).parameters["budget_usd"].default)) + +#: Average days per month (365.25 / 12 = 30.4375): the per-buy formula's month. +MONTH_DAYS = Decimal("365.25") / Decimal("12") +DEFAULT_CADENCE_DAYS = 7 + +_CENT = Decimal("0.01") + +#: An operator-typed budget above this is not a realistic monthly USD figure. Quantizing a +#: `Decimal` to cents needs (integer digits + 2) <= the context's precision (28 by default); +#: past that, `.quantize()` raises `decimal.InvalidOperation` instead of rounding -- exactly +#: what `--budget 1e30` did (defect, review of #846): a raw traceback instead of a clean usage +#: error. This bound is refused well before that ceiling, at a figure no real monthly DCA budget +#: could reach. +MAX_BUDGET_USD = Decimal("1e12") + + +class DcaPlanError(ValueError): + """An operator input the plan cannot be built from. The message is shown verbatim.""" + + +@dataclass(frozen=True) +class PlanInputs: + budget_usd: Decimal + buffer_pct: Decimal + cadence_days: int = DEFAULT_CADENCE_DAYS + + +def _decimal(raw: str, name: str) -> Decimal: + try: + value = Decimal(str(raw).strip()) + except InvalidOperation as exc: + raise DcaPlanError(f"{name} {raw!r} is not a number") from exc + if not value.is_finite(): + raise DcaPlanError(f"{name} {raw!r} is not a finite number") + return value + + +def parse_plan_inputs( + budget: str, buffer_pct: str, cadence_days: int = DEFAULT_CADENCE_DAYS +) -> PlanInputs: + """The one parse of the plan's inputs, shared by the CLI and `/api/dca-plan` so the two + refuse the same inputs with the same words.""" + budget_usd = _decimal(budget, "budget") + if budget_usd <= 0: + raise DcaPlanError(f"budget must be positive, got {budget!r}") + if budget_usd > MAX_BUDGET_USD: + raise DcaPlanError( + f"budget {budget!r} is not a realistic monthly USD amount (over " + f"{format(MAX_BUDGET_USD, ',f')}) -- refused before it could break Decimal rounding" + ) + buffer = _decimal(buffer_pct, "buffer-pct") + if not (Decimal("0") <= buffer < Decimal("1")): + raise DcaPlanError( + f"buffer-pct is a fraction in [0, 1), got {buffer_pct!r} -- 0.1 means hold back 10%" + ) + if cadence_days < 1: + raise DcaPlanError(f"cadence-days must be at least 1, got {cadence_days}") + return PlanInputs(budget_usd=budget_usd, buffer_pct=buffer, cadence_days=cadence_days) + + +def parse_weight(raw: str, asset: str) -> Decimal: + """One `[E]`-edited weight: finite and >= 0 (0 excludes the asset for this session).""" + value = _decimal(raw, f"weight for {asset}") + if value < 0: + raise DcaPlanError(f"weight for {asset} must be 0 or more, got {raw!r}") + return value + + +@dataclass(frozen=True) +class BuyCap: + """Rail 14's monthly BUY cap for one venue. `allowance_usd is None` means unlimited.""" + + venue: str + allowance_usd: Decimal | None + #: "" when the record is in force; otherwise rail 14's own words for why it is not. + degraded_reason: str + pacing: str + + @property + def in_force(self) -> bool: + return self.degraded_reason == "" + + +def monthly_buy_cap(repo: Repository, config: Config, *, venue: str, now_ts: int) -> BuyCap: + """The allowance rail 14 enforces for `venue`, read the way `guards.check` reads it + (`guards.py` rail 14): no record -> `unsubscribed_allowance_usd`; a record -> its + `allowance_usd(now_ts, unsubscribed)`, which already degrades suspect/lapsed/overdue. The + reason words are rail 14's, in its order (lapsed is reported ahead of overdue). Parity is + pinned by driving `guards.check` at the cap and a cent over it.""" + unsubscribed = config.subscription.unsubscribed_allowance_usd + record = repo.get_broker_subscription(venue) + if record is None: + return BuyCap( + venue, unsubscribed, "no subscription has been attested", config.subscription.pacing + ) + effective = record.effective_status(now_ts) + if effective is SubscriptionStatus.ACTIVE: + reason = "" + elif record.status is SubscriptionStatus.LAPSED: + reason = "its subscription is lapsed" + elif record.attest_due_ts <= now_ts: + reason = "its attestation is overdue" + else: + reason = f"its subscription is {effective.value}" + return BuyCap(venue, record.allowance_usd(now_ts, unsubscribed), reason, record.pacing) + + +ExclusionReason = Literal["not_on_allowlist", "not_admitted", "no_weight", "has_dca_rule"] + +#: The operator-facing words for each reason. One table, read by the terminal renderer and the +#: web payload alike. +REASON_TEXT: dict[str, str] = { + "not_on_allowlist": "not on the allowlist", + "not_admitted": "not admitted by the screen", + "no_weight": "no positive target weight", + "has_dca_rule": "already has a DCA rule -- existing, unchanged", +} + +_NON_EXISTING = frozenset({"disabled"}) + + +@dataclass(frozen=True) +class ExcludedAsset: + asset: str + reasons: tuple[ExclusionReason, ...] + detail: str + + +@dataclass(frozen=True) +class ExistingDcaRule: + rule_id: int + asset: str + product_id: str + status: str + budget_usd: Decimal + cadence_days: int + #: Coordinator amendment (2026-09-27), for Task 3's amended R7: the ceiling `dip_bonus_pct` + #: rule params carry. Not itself modelled into the live commitment sum -- a row with + #: `dip_bonus_pct > 0` gets a warning instead, since #843 sizes a live DCA buy from the rule's + #: own `budget_usd` context and the dip bonus can push a single buy above that figure. + dip_bonus_pct: Decimal + #: True when the stored params carry no `budget_usd` and `budget_usd` above is + #: `DCA_DEFAULT_BUDGET_USD`, the figure the rule would be built with -- inferred, not stored, + #: so a live row like this gets its own named warning (review of #846). + budget_inferred: bool = False + + +@dataclass(frozen=True) +class Allocation: + asset: str + product_id: str + raw_weight: Decimal + #: `raw_weight` renormalised over the allocated set; the allocations' weights sum to 1. + weight: Decimal + + +@dataclass(frozen=True) +class Universe: + allocations: tuple[Allocation, ...] + excluded: tuple[ExcludedAsset, ...] + existing: tuple[ExistingDcaRule, ...] + #: `(asset, current raw weight)` for every asset `[E]` may edit (R14), allowlist order. + editable: tuple[tuple[str, Decimal], ...] + + +def existing_dca_rules(repo: Repository) -> tuple[ExistingDcaRule, ...]: + """Every non-disabled `dca` row, in id order. Read fresh by `apply_dca_plan` too.""" + found: list[ExistingDcaRule] = [] + for row in repo.get_rules(): + if row["kind"] != "dca" or row["status"] in _NON_EXISTING: + continue + params = row["params"] or {} + product = str(params.get("product_id", "")) + # A row missing `budget_usd` entirely (legacy/malformed) is built by + # `agent.build_rule_from_params` with `Dca.__init__`'s own default -- + # `DCA_DEFAULT_BUDGET_USD` -- not $0 (review of #846: $0 undercounted a live row's R7 + # commitment) and not `config.dca.budget_usd` (the executor's fallback, reached only when + # a setup carries no `size_usd` at all -- never true of a `Dca` rule's own buy). + raw_budget = params.get("budget_usd") + budget_inferred = raw_budget is None + budget_usd = DCA_DEFAULT_BUDGET_USD if budget_inferred else Decimal(str(raw_budget)) + found.append( + ExistingDcaRule( + rule_id=int(row["id"]), + # `_asset_of` (rail 1's key function) does NOT uppercase its result -- confirmed + # by reading `guards._asset` -- so the `.upper()` here is this module's own, + # deliberate on top of it. + asset=_asset_of(product).upper(), + product_id=product, + status=str(row["status"]), + budget_usd=budget_usd, + cadence_days=int(params.get("cadence_days", DEFAULT_CADENCE_DAYS)), + dip_bonus_pct=Decimal(str(params.get("dip_bonus_pct", "0"))), + budget_inferred=budget_inferred, + ) + ) + return tuple(found) + + +def _weights_by_asset(raw: Mapping[str, Decimal], source: str) -> dict[str, Decimal]: + """Uppercase every key, refusing -- never silently dropping (#848) -- when two keys collide + only by case. `{"btc": .9, "BTC": .1}` uppercased by a plain dict comprehension lets + whichever key iterates last overwrite the other, and the loser's weight vanishes with + nothing said: exactly the silent-drop class R4 (`not_on_allowlist`) exists to prevent for + every OTHER way a weight can disappear. Both callers below (`target_weights`, the `[E]` + override) share this one guard so neither can drop a weight the other one catches.""" + by_upper: dict[str, list[str]] = {} + for key in raw: + by_upper.setdefault(key.upper(), []).append(key) + collisions = {upper: keys for upper, keys in by_upper.items() if len(keys) > 1} + if collisions: + detail = "; ".join( + f"{upper} ({', '.join(sorted(keys))})" for upper, keys in sorted(collisions.items()) + ) + raise DcaPlanError( + f"{source} has keys that collide once uppercased -- {detail} -- fix the config; " + "nothing was silently dropped" + ) + parsed = {key: Decimal(str(w)) for key, w in raw.items()} + # A `.nan` / `.inf` weight passes `load_config`; refused here, naming the key, rather than + # as an `InvalidOperation` traceback from the `<= 0` comparison downstream. + non_finite = sorted(key for key, w in parsed.items() if not w.is_finite()) + if non_finite: + raise DcaPlanError(f"{source} has a non-finite weight for {', '.join(non_finite)}") + return {key.upper(): w for key, w in parsed.items()} + + +def select_universe( + repo: Repository, + config: Config, + *, + screen_fn: ScreenFn, + weights_override: Mapping[str, Decimal] | None = None, +) -> Universe: + """allowlist ∩ admitted ∩ positive weight, minus assets with a non-disabled DCA rule. + + Admission comes from `build_screen_report` with the injected `screen_fn` + (`keel.commands.assets.screen_product` in production) -- the one gate every candidate source + routes through, never a laxer copy. READ-ONLY: this writes nothing. + """ + quote = config.quote_currency + # #849: the allowlist names assets, so a repeat (`[BTC, ETH, btc]` passes `load_config`) is + # the SAME asset -- one allocation, one buy, one rule. `dict.fromkeys` keeps first-seen order; + # nothing is dropped, because a repeat carries no weight of its own. + allowlist = list(dict.fromkeys(asset.upper() for asset in config.allowlist)) + weights = _weights_by_asset(config.target_weights, "target_weights") + admitted = { + sp.asset.upper(): sp for sp in build_screen_report(repo, config, screen_fn).screened + } + existing = existing_dca_rules(repo) + existing_by_asset = {rule.asset: rule for rule in existing} + + editable_assets = [ + asset + for asset in allowlist + if asset in admitted and admitted[asset].result.admitted and asset not in existing_by_asset + ] + override = _weights_by_asset(weights_override or {}, "the edited weights") + stray = sorted(set(override) - set(editable_assets)) + if stray: + raise DcaPlanError( + f"cannot edit the weight of {', '.join(stray)}: only admitted, allowlisted assets " + "with no DCA rule are in this plan" + ) + effective = {**weights, **override} + + excluded: list[ExcludedAsset] = [] + chosen: list[tuple[str, Decimal]] = [] + for asset in allowlist: + reasons: list[ExclusionReason] = [] + details: list[str] = [] + screened = admitted.get(asset) + if screened is None or not screened.result.admitted: + reasons.append("not_admitted") + details.append("; ".join(screened.result.failures) if screened else "not screened") + weight = effective.get(asset, Decimal("0")) + if weight <= 0: + reasons.append("no_weight") + if asset in override: + details.append("set to 0 in this session") + rule = existing_by_asset.get(asset) + if rule is not None: + reasons.append("has_dca_rule") + details.append(f"rule {rule.rule_id} ({rule.status})") + if reasons: + excluded.append( + ExcludedAsset(asset, tuple(reasons), "; ".join(d for d in details if d)) + ) + else: + chosen.append((asset, weight)) + for asset in sorted(set(weights) - set(allowlist)): + if weights[asset] > 0: + excluded.append(ExcludedAsset(asset, ("not_on_allowlist",), "")) + + total = sum((w for _, w in chosen), Decimal("0")) + allocations = tuple( + Allocation(asset, _history_product(asset, quote), w, w / total) for asset, w in chosen + ) + editable = tuple((asset, effective.get(asset, Decimal("0"))) for asset in editable_assets) + return Universe(allocations, tuple(excluded), existing, editable) + + +def apply_command( + inputs: PlanInputs | None, *, config_path: str | None = None, db_path: str | None = None +) -> str: + """The exact `keel dca plan` invocation for `inputs` -- `format(x, "f")` so a `1e3` budget is + spelled `1000`, never `1E+3`. `None` gives the template the web card shows before a budget + is chosen. Global options (`--config`/`--db`) precede the group, as click requires.""" + head = ["keel"] + if config_path is not None: + head += ["--config", shlex.quote(config_path)] + if db_path is not None: + head += ["--db", shlex.quote(db_path)] + if inputs is None: + return " ".join([*head, "dca plan --budget --buffer-pct "]) + parts = [ + *head, + "dca", + "plan", + "--budget", + format(inputs.budget_usd, "f"), + "--buffer-pct", + format(inputs.buffer_pct, "f"), + ] + if inputs.cadence_days != DEFAULT_CADENCE_DAYS: + parts += ["--cadence-days", str(inputs.cadence_days)] + return " ".join(parts) + + +_HUNDRED = Decimal("100") + +#: keel records no venue minimum order size: `keel_broker_api.results.Instrument` carries none +#: ("Minimum sizes are still absent for the original reason -- nothing reads them"). +MIN_ORDER_UNKNOWN = ( + "keel does not record the venue's minimum order size (its instrument record carries none), " + "so no per-buy amount was checked against it -- confirm each against the venue's product " + "minimum before promoting." +) + + +@dataclass(frozen=True) +class PlannedBuy: + asset: str + product_id: str + weight: Decimal + #: `weight x 100`, computed here so no front-end multiplies (payload.py Rule 2). + weight_pct: Decimal + cadence_days: int + per_buy_usd: Decimal + monthly_usd: Decimal + est_monthly_fee_usd: Decimal + #: The venue minimum, when keel knows it. It never does today -- see MIN_ORDER_UNKNOWN. + min_order_usd: Decimal | None + + +@dataclass(frozen=True) +class DcaPlan: + inputs: PlanInputs + spend_usd: Decimal + buffer_usd: Decimal + buys: tuple[PlannedBuy, ...] + buy_count: int + excluded: tuple[ExcludedAsset, ...] + existing: tuple[ExistingDcaRule, ...] + editable: tuple[tuple[str, Decimal], ...] + cap: BuyCap + planned_monthly_usd: Decimal + est_monthly_fees_usd: Decimal + taker_pct: Decimal + taker_pct_display: Decimal + #: Sum, over each non-disabled `live` DCA row, of `_cents_down(rule.budget_usd x MONTH_DAYS / + #: rule.cadence_days)` -- that rule's OWN per-buy amount, because since #843 the live executor + #: sizes each DCA buy from the rule's own `setup.context["size_usd"]` + #: (`keel/execution/executor.py::_dca_budget`), not from `config.dca.budget_usd` (R7, + #: amended after #843; R9, which claimed the executor sizes every buy from the config figure, + #: is withdrawn -- that statement is no longer true of the money path). This is still the + #: AVERAGE-month figure shown to the operator; the warning that uses it is triggered by the + #: worst-case figures below instead (R7 amended again, #847). + existing_live_monthly_usd: Decimal + #: R6 amended 2026-09-27, #847: rail 14 caps the UTC CALENDAR month + #: (`guards._monthly_buy_spend_usd`), not the 30.4375-day average the per-buy SIZING still + #: uses (unchanged). Every DCA rule in one plan shares one cadence and buys on the same days + #: (`epoch_day % cadence_days == 0`, `Dca.detect`), so the worst calendar month for that + #: cadence -- `_worst_month_buy_days` -- can hold more buys than the average-month `spend` + #: figure implies (5 for a 7-day cadence, e.g. a 31-day month phased on the 1st). This field + #: is that count. + worst_month_buy_days: int + #: The total spent, across every planned buy, on ONE cadence day -- exact: each addend + #: (`buy.per_buy_usd`) is already rounded down to cents, and summing introduces no further + #: rounding. + worst_month_cycle_usd: Decimal + #: `worst_month_cycle_usd x worst_month_buy_days` -- what the blocker check (R6) and the + #: renderer both use. This field IS the figure that was checked against the cap; the + #: renderer reuses it verbatim rather than recomputing, so the display can never show a + #: total larger than what was actually checked (R5's rule, extended to this figure by #847). + worst_month_spend_usd: Decimal + blockers: tuple[str, ...] + warnings: tuple[str, ...] + + @property + def approvable(self) -> bool: + return not self.blockers + + +def _cents_down(value: Decimal) -> Decimal: + return value.quantize(_CENT, rounding=ROUND_DOWN) + + +def _usd(value: Decimal) -> str: + return f"${format(value.quantize(_CENT), ',f')}" + + +def _worst_month_buy_days(cadence_days: int) -> int: + """The most buy days a `cadence_days`-cadence schedule (`epoch_day % cadence_days == 0`, + `Dca.detect`'s own scheduling rule -- not this module's) can land inside any single UTC + calendar month, over every month length (28-31 days) and every phase the cadence can fall + into. Exact, computed from the scheduling rule itself (brute force over the small, bounded + space of month lengths and phases), not the 30.4375-day average `MONTH_DAYS` uses for + per-buy sizing -- rail 14 caps the CALENDAR month + (`keel/execution/guards.py::_monthly_buy_spend_usd`), so this is the figure a blocker must + compare against (#847). For a 7-day cadence this is 5 (a 31-day month phased on the 1st: + days 1, 8, 15, 22, 29).""" + worst = 0 + for length in (28, 29, 30, 31): + for phase in range(min(cadence_days, length)): + hits = sum(1 for day in range(length) if day % cadence_days == phase) + worst = max(worst, hits) + return worst + + +def build_dca_plan( + repo: Repository, + config: Config, + inputs: PlanInputs, + *, + venue: str, + now_ts: int, + screen_fn: ScreenFn, + weights_override: Mapping[str, Decimal] | None = None, +) -> DcaPlan: + """The whole proposal, every figure computed here and nowhere downstream. READ-ONLY. + + Per buy: `monthly share x cadence_days / (365.25/12)`, rounded DOWN to cents; the monthly + total is recomputed from that rounded buy and rounded down again; the fee estimate at the + CONFIGURED `fees.taker_pct` is rounded UP (R5). Blockers (R6, R8) make the plan + unapprovable; warnings never do: + + - R6 amended 2026-09-27, #847: the cap check compares the WORST UTC CALENDAR month for this + plan's cadence (`_worst_month_buy_days(cadence) x` the per-cycle total, `worst_month_*` on + `DcaPlan`) against rail 14's cap -- not the 30.4375-day average `spend` alone, because rail + 14 caps the calendar month (`guards._monthly_buy_spend_usd`) and every rule in this plan + buys on the same days. Per-buy SIZING is unchanged; only the blocker's comparison moved. + - R7 (amended after #843, amended again by #847 for consistency): each existing `live` DCA + row's monthly commitment is still shown at that rule's OWN `budget_usd`, average-month + figure -- not `config.dca.budget_usd` -- but the WARNING is now triggered by the same + worst-calendar-month arithmetic as the blocker: this plan's worst month plus each live + row's own worst month (its own cadence), against the cap. + - R7's dip-bonus corollary: a `live` row with `dip_bonus_pct > 0` can spend more than the + commitment above on any given buy, so it gets its own warning naming the rule. + - The minimum-order gap: keel does not know the venue's minimum order size + (`MIN_ORDER_UNKNOWN`). + - Rail 14's `even_daily` pacing, when the attested record uses it. + """ + universe = select_universe(repo, config, screen_fn=screen_fn, weights_override=weights_override) + cap = monthly_buy_cap(repo, config, venue=venue, now_ts=now_ts) + cadence = inputs.cadence_days + spend = _cents_down(inputs.budget_usd * (Decimal("1") - inputs.buffer_pct)) + taker = config.fees.taker_pct + + buys: list[PlannedBuy] = [] + blockers: list[str] = [] + for allocation in universe.allocations: + per_buy = _cents_down(spend * allocation.weight * cadence / MONTH_DAYS) + monthly = _cents_down(per_buy * MONTH_DAYS / cadence) + buys.append( + PlannedBuy( + asset=allocation.asset, + product_id=allocation.product_id, + weight=allocation.weight, + weight_pct=(allocation.weight * _HUNDRED).quantize(Decimal("0.1")), + cadence_days=cadence, + per_buy_usd=per_buy, + monthly_usd=monthly, + est_monthly_fee_usd=(monthly * taker).quantize(_CENT, rounding=ROUND_UP), + min_order_usd=None, + ) + ) + if per_buy <= 0: + blockers.append( + f"{allocation.asset}'s per-buy rounds to $0.00 -- raise the budget or its weight" + ) + elif per_buy > config.caps.max_per_order_usd: + blockers.append( + f"{allocation.asset}'s per-buy {_usd(per_buy)} exceeds caps.max_per_order_usd " + f"{_usd(config.caps.max_per_order_usd)}; that rail would veto every buy" + ) + if not buys: + blockers.append("no asset is eligible -- see the excluded list for each reason") + + # R6, amended #847: the worst UTC CALENDAR month this cadence can land in, not the + # 30.4375-day average `spend` alone -- rail 14 caps the calendar month + # (`guards._monthly_buy_spend_usd`), and every buy in this plan shares one cadence and lands + # on the same days (`epoch_day % cadence_days == 0`). `worst_month_cycle_usd` sums buys that + # are ALREADY rounded down to cents, so this introduces no further rounding -- and the + # renderer reuses these exact fields rather than recomputing them (R5, extended). + worst_days = _worst_month_buy_days(cadence) + worst_month_cycle = sum((b.per_buy_usd for b in buys), Decimal("0")) + worst_month_spend = worst_month_cycle * worst_days + if cap.allowance_usd is not None and worst_month_spend > cap.allowance_usd: + if cap.in_force: + blockers.append( + f"planned spend {_usd(spend)}/month; the worst calendar month for a " + f"{cadence}-day cadence holds {worst_days} buy day(s), which at " + f"{_usd(worst_month_cycle)} per cycle is {_usd(worst_month_spend)} -- that " + f"exceeds rail 14's monthly buy cap {_usd(cap.allowance_usd)} on {cap.venue}" + ) + else: + blockers.append( + f"rail 14's monthly buy cap on {cap.venue} is {_usd(cap.allowance_usd)} because " + f"{cap.degraded_reason}; the worst calendar month for a {cadence}-day cadence " + f"would spend {_usd(worst_month_spend)} ({worst_days} buy day(s) x " + f"{_usd(worst_month_cycle)} per cycle). Run `keel subscription attest --venue " + f"{cap.venue} --tier ` to restore it." + ) + + live_rules = [rule for rule in universe.existing if rule.status == "live"] + live_monthly = sum( + (_cents_down(rule.budget_usd * MONTH_DAYS / rule.cadence_days) for rule in live_rules), + Decimal("0"), + ) + # R7 amended #847, for consistency with R6: the TRIGGER compares worst calendar months too -- + # this plan's own worst month plus each live row's own worst month, at its own cadence and + # its own budget_usd (exact: an integer count times an already-exact stored amount, no + # rounding). The DISPLAYED `live_monthly` figure above stays the average-month one operators + # already read this warning by. + live_worst_monthly = sum( + (rule.budget_usd * _worst_month_buy_days(rule.cadence_days) for rule in live_rules), + Decimal("0"), + ) + warnings: list[str] = [MIN_ORDER_UNKNOWN] + allowance = cap.allowance_usd + combined_worst = worst_month_spend + live_worst_monthly + if allowance is not None and live_monthly > 0 and combined_worst > allowance: + warnings.append( + f"existing live DCA rules commit about {_usd(live_monthly)}/month, at each rule's " + f"own amount; combined with this plan's worst calendar month total " + f"{_usd(worst_month_spend)}, the worst-case combined total is " + f"{_usd(combined_worst)}, which exceeds rail 14's monthly buy cap {_usd(allowance)}, " + "which will veto buys once the month's total reaches it" + ) + for rule in live_rules: + if rule.budget_inferred: + warnings.append( + f"rule {rule.rule_id} ({rule.product_id}) has no stored budget_usd; its " + f"commitment above is counted at the Dca rule's default " + f"{_usd(DCA_DEFAULT_BUDGET_USD)} per buy, which is what the rule is built with" + ) + if rule.dip_bonus_pct > 0: + warnings.append( + f"rule {rule.rule_id} ({rule.product_id}) has dip_bonus_pct " + f"{rule.dip_bonus_pct}: each 1-point drawdown from its recent high adds " + f"{rule.dip_bonus_pct}% of its budget to a buy, so its buys can exceed both its " + "listed per-buy amount and the commitment shown above" + ) + if cap.pacing == "even_daily": + warnings.append( + "rail 14 pacing is even_daily: the cap is also paced per business day, so an early-" + "month buy can be vetoed below the full-month figure shown" + ) + + return DcaPlan( + inputs=inputs, + spend_usd=spend, + buffer_usd=_cents_down(inputs.budget_usd) - spend, + buys=tuple(buys), + buy_count=len(buys), + excluded=universe.excluded, + existing=universe.existing, + editable=universe.editable, + cap=cap, + planned_monthly_usd=sum((b.monthly_usd for b in buys), Decimal("0")), + est_monthly_fees_usd=sum((b.est_monthly_fee_usd for b in buys), Decimal("0")), + taker_pct=taker, + taker_pct_display=taker * _HUNDRED, + existing_live_monthly_usd=live_monthly, + worst_month_buy_days=worst_days, + worst_month_cycle_usd=worst_month_cycle, + worst_month_spend_usd=worst_month_spend, + blockers=tuple(blockers), + warnings=tuple(warnings), + ) + + +#: Stated once per plan, verbatim (#836). The attested figure is a cap keel imposes on its own +#: buying; Advanced Trade charges its fee on every order regardless. +RAIL14_NOTE = ( + "Rail 14 is a monthly BUY cap keel imposes on its own buying -- not a fee waiver: every " + "order pays the venue's fee." +) + + +def render_dca_plan(plan: DcaPlan) -> list[str]: + """The CLI's exact lines. Sections are `== Title ==` headers so the CLI and tests read them + the same way.""" + inputs = plan.inputs + cap = "unlimited" if plan.cap.allowance_usd is None else _usd(plan.cap.allowance_usd) + lines = [ + "== DCA plan ==", + f" budget {_usd(inputs.budget_usd)}/month, buffer {format(inputs.buffer_pct, 'f')} " + f"({_usd(plan.buffer_usd)} held back) -> spend {_usd(plan.spend_usd)}/month", + f" rail 14 monthly buy cap on {plan.cap.venue}: {cap}" + + ("" if plan.cap.in_force else f" (because {plan.cap.degraded_reason})"), + # #847: shown right next to the cap, and reusing the plan's own fields verbatim -- never + # a total recomputed (and possibly larger) than what the blocker check actually used. + f" checked against the cap: worst calendar month for a {inputs.cadence_days}-day " + f"cadence, {plan.worst_month_buy_days} buy day(s) x {_usd(plan.worst_month_cycle_usd)} " + f"per cycle = {_usd(plan.worst_month_spend_usd)}", + f" {RAIL14_NOTE}", + "", + "== Schedule ==", + f" {'asset':<8} {'weight':>7} {'cadence':<14} {'per buy':>10} {'monthly':>10} " + f"{'est. fee':>9}", + ] + for buy in plan.buys: + lines.append( + f" {buy.asset:<8} {format(buy.weight_pct, 'f') + '%':>7} " + f"{'every ' + str(buy.cadence_days) + ' days':<14} {_usd(buy.per_buy_usd):>10} " + f"{_usd(buy.monthly_usd):>10} {_usd(buy.est_monthly_fee_usd):>9}" + ) + lines.append( + f" total {_usd(plan.planned_monthly_usd)}/month, est. fees " + f"{_usd(plan.est_monthly_fees_usd)}/month at the configured fees.taker_pct " + f"{_trim_pct(plan.taker_pct_display)}%" + ) + if plan.excluded: + lines += ["", "== Excluded =="] + for item in plan.excluded: + reasons = ", ".join(REASON_TEXT[r] for r in item.reasons) + detail = f" ({item.detail})" if item.detail else "" + lines.append(f" {item.asset} -- {reasons}{detail}") + if plan.existing: + lines += ["", "== Existing DCA rules (unchanged) =="] + for rule in plan.existing: + row = ( + f" [{rule.rule_id}] {rule.product_id} {rule.status} " + f"{_usd(rule.budget_usd)} every {rule.cadence_days} days" + ) + if rule.dip_bonus_pct > 0: + row += f" (dip_bonus_pct {rule.dip_bonus_pct})" + lines.append(row) + if plan.blockers: + lines += ["", "== Cannot approve =="] + [f" ✗ {b}" for b in plan.blockers] + lines += ["", "== Notes =="] + [f" ! {w}" for w in plan.warnings] + return lines + + +def _trim_pct(value: Decimal) -> str: + """`1.200` -> `1.2`; `format(..., "f")` keeps it exponent-free, and `.rstrip` only trims.""" + text = format(value, "f") + return text.rstrip("0").rstrip(".") if "." in text else text + + +# -- the all-or-nothing `candidate` write --------------------------------------------------------- + + +class DcaPlanRefused(RuntimeError): + """Approval refused. Raised before the first insert, so nothing was written -- except the + one case R2 names (a row failing AFTER pre-validation), whose message lists what was.""" + + +def _noop(message: str) -> None: + del message + + +def _rule_params(buy: PlannedBuy) -> dict[str, object]: + return {"cadence_days": buy.cadence_days, "budget_usd": format(buy.per_buy_usd, "f")} + + +def apply_dca_plan( + repo: Repository, + config: Config, + plan: DcaPlan, + *, + now_ts: int, + echo: Callable[[str], None] = _noop, + echo_err: Callable[[str], None] = _noop, +) -> tuple[RulesOutcome, ...]: + """Write one `candidate` `dca` rule per planned buy, through `keel rules add`'s own service. + + All-or-nothing (R2): refuse a blocked plan; re-read the rules table and refuse if any planned + asset gained a non-disabled DCA rule since the preview; construct every rule (rails 18/19 + + `build_rule_from_params`) before the FIRST insert. Never promotes; never writes paper/live; + never touches an existing row -- `add_rule_row` writes `candidate` and nothing else. + + `add_rule_row` commits per row, so it is not itself transactional across a whole plan: a row + that fails AFTER pre-validation has passed (a locked database -- `sqlite3.Error` -- rather + than anything `RulesRefused`/`RulesUsageError` names) still leaves the earlier rows written. + That refusal names their ids (R2's stated cost if wrong). + """ + if plan.blockers: + raise DcaPlanRefused("the plan cannot be approved: " + "; ".join(plan.blockers)) + fresh = {rule.asset: rule for rule in existing_dca_rules(repo)} + stale = [buy.asset for buy in plan.buys if buy.asset in fresh] + if stale: + raise DcaPlanRefused( + f"{', '.join(stale)} gained a DCA rule since this plan was shown " + f"({', '.join(f'rule {fresh[a].rule_id}' for a in stale)}); nothing was written -- " + "re-run `keel dca plan` to see the current state" + ) + for buy in plan.buys: + try: + parse_products_option(buy.product_id, config) + agent.build_rule_from_params("dca", {**_rule_params(buy), "product_id": buy.product_id}) + except (ValueError, TypeError, ArithmeticError) as exc: + raise DcaPlanRefused(f"{buy.asset}: {exc}; nothing was written") from exc + + outcomes: list[RulesOutcome] = [] + for buy in plan.buys: + try: + outcomes.append( + add_rule_row( + repo, + config, + kind="dca", + product=buy.product_id, + params_json=json.dumps(_rule_params(buy)), + now_ts=now_ts, + echo=echo, + echo_err=echo_err, + ) + ) + except (RulesRefused, RulesUsageError, sqlite3.Error) as exc: + written = ", ".join(str(o.rule_id) for o in outcomes) or "none" + raise DcaPlanRefused(f"{buy.asset}: {exc}; rules already written: {written}") from exc + return tuple(outcomes) diff --git a/tests/commands/test_dca_cli.py b/tests/commands/test_dca_cli.py new file mode 100644 index 00000000..1577bea7 --- /dev/null +++ b/tests/commands/test_dca_cli.py @@ -0,0 +1,327 @@ +"""`keel dca plan` -- the thin CLI over `keel.commands.dca_plan`. + +Pins: off a TTY it prints and writes NOTHING (exit 0 approvable / 1 blocked, R12); at a TTY, +[Y] writes candidates, [N] writes nothing, [E] re-renders with edited weights before [Y]. + +R20: the command decides interactivity FIRST (`_common._is_interactive()`, attribute access), +THEN opens the database -- off a TTY with `_common._open_repo_ro(ctx)` (read-only, refuses a +missing file or a stale schema before any write could happen), at a TTY with `_common._open_repo`. +That ordering is what makes "off a TTY it writes nothing" hold even for a missing `--db` path +(a plain connect would create the file) or an old schema (`migrate()` would write). +""" + +from __future__ import annotations + +import sqlite3 +import time +from decimal import Decimal +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from keel.cli import cli +from keel.commands import _common +from keel.commands import dca as dca_cli +from keel.data.db import connect, migrate +from keel.data.repository import Repository +from tests.commands.test_dca_plan import _screen +from tests.conftest import attest_subscription + + +def _repo(db: Path) -> Repository: + conn = connect(str(db)) + migrate(conn) + return Repository(conn) + + +#: #847: the default $500/0.1 budget/buffer's worst calendar month is $517.35 (5 x $103.47, +#: BTC/ETH/PAXG .4/.3/.3) or $517.40 (5 x $103.48, any renormalised 2-of-3 subset of the same +#: total) -- both exceed a $500 cap. 600 clears either, so this fixture's happy-path tests (about +#: CLI plumbing, not the cap defect itself) stay approvable; `test_dca_plan.py` pins the $500 cap +#: defect directly. +_ROOMY_CAP = Decimal("600") + + +@pytest.fixture +def deployment(tmp_path: Path, valid_config_path: Path, monkeypatch: pytest.MonkeyPatch): + db = tmp_path / "t.db" + attest_subscription(_repo(db), now_ts=int(time.time()), free_volume_usd=_ROOMY_CAP) + monkeypatch.setattr(dca_cli, "screen_product", _screen()) + return db, valid_config_path + + +def _run(deployment, *args: str, input: str | None = None): + db, config = deployment + return CliRunner().invoke( + cli, + ["--db", str(db), "--config", str(config), "dca", "plan", *args], + input=input, + ) + + +def test_off_a_tty_it_prints_the_plan_and_writes_nothing(deployment, monkeypatch) -> None: + monkeypatch.setattr(_common, "_is_interactive", lambda: False) + # `PRAGMA data_version` on ONE connection held across the run changes iff ANOTHER connection + # committed -- `total_changes` would not do here, it is per-connection. The fixture already + # migrated the file, so an up-to-date `migrate()` inside the command has nothing to commit. + watcher = sqlite3.connect(str(deployment[0])) + before = watcher.execute("PRAGMA data_version").fetchone()[0] + + result = _run(deployment, "--budget", "500", "--buffer-pct", "0.1") + + assert result.exit_code == 0, result.output + assert "== Schedule ==" in result.output and "$41.39" in result.output + assert "nothing written" in result.output + assert watcher.execute("PRAGMA data_version").fetchone()[0] == before + watcher.close() + assert _repo(deployment[0]).get_rules() == [] + # For the report: the exact CLI output of an off-TTY, approvable run. + print(result.output) + + +def test_off_a_tty_a_blocked_plan_exits_1(deployment, monkeypatch) -> None: + monkeypatch.setattr(_common, "_is_interactive", lambda: False) + result = _run(deployment, "--budget", "5000", "--buffer-pct", "0") # over the 500 cap + assert result.exit_code == 1 + assert "== Cannot approve ==" in result.output + assert _repo(deployment[0]).get_rules() == [] + + +def test_off_a_tty_a_missing_db_path_is_refused_and_creates_nothing( + tmp_path: Path, valid_config_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """R20: a missing `--db` path off a TTY must not be created. `_open_repo` (read-write) + would `connect()` it into existence and `migrate()` it; `_open_repo_ro` refuses first.""" + monkeypatch.setattr(_common, "_is_interactive", lambda: False) + missing = tmp_path / "nope.db" + result = CliRunner().invoke( + cli, + [ + "--db", + str(missing), + "--config", + str(valid_config_path), + "dca", + "plan", + "--budget", + "500", + "--buffer-pct", + "0.1", + ], + ) + assert result.exit_code != 0 + assert not missing.exists() + + +def test_off_a_tty_the_database_is_opened_read_only(deployment, monkeypatch) -> None: + """R20: prove the off-TTY path never reaches `_common._open_repo` (the read-write opener) -- + patch it to raise, and show the off-TTY happy path still exits 0. The same patch then makes + the TTY path fail, proving the patch reaches whichever branch actually calls it.""" + + def _boom(ctx): + raise AssertionError("rw open off a TTY") + + monkeypatch.setattr(_common, "_open_repo", _boom) + monkeypatch.setattr(_common, "_is_interactive", lambda: False) + result = _run(deployment, "--budget", "500", "--buffer-pct", "0.1") + assert result.exit_code == 0, result.output + + monkeypatch.setattr(_common, "_is_interactive", lambda: True) + result_tty = _run(deployment, "--budget", "500", "--buffer-pct", "0.1", input="N\n") + assert result_tty.exit_code != 0, result_tty.output + + +def test_off_a_tty_open_repo_ro_is_called_exactly_once(deployment, monkeypatch) -> None: + """The counter variant of the same pin: the off-TTY path opens the DB read-only, once.""" + calls = {"n": 0} + real = _common._open_repo_ro + + def counting(ctx): + calls["n"] += 1 + return real(ctx) + + monkeypatch.setattr(_common, "_open_repo_ro", counting) + monkeypatch.setattr(_common, "_is_interactive", lambda: False) + result = _run(deployment, "--budget", "500", "--buffer-pct", "0.1") + assert result.exit_code == 0, result.output + assert calls["n"] == 1 + + +def test_the_screen_fn_is_called_once_per_allowlisted_product( + tmp_path: Path, valid_config_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Verify the `deployment` fixture's monkeypatch of `dca_cli.screen_product` actually reaches + the reader: an empty fixture that never gets called would pass every other test in this file + by vacuum. VALID_CONFIG_YAML's allowlist is BTC/ETH/PAXG -- exactly three products.""" + db = tmp_path / "t.db" + attest_subscription(_repo(db), now_ts=int(time.time()), free_volume_usd=_ROOMY_CAP) + calls: list[str] = [] + real = _screen() + + def counting(repo, product, quote): + calls.append(product) + return real(repo, product, quote) + + monkeypatch.setattr(dca_cli, "screen_product", counting) + monkeypatch.setattr(_common, "_is_interactive", lambda: False) + + result = CliRunner().invoke( + cli, + [ + "--db", + str(db), + "--config", + str(valid_config_path), + "dca", + "plan", + "--budget", + "500", + "--buffer-pct", + "0.1", + ], + ) + assert result.exit_code == 0, result.output + assert len(calls) == 3 + assert set(calls) == {"BTC-USD", "ETH-USD", "PAXG-USD"} + + +def test_yes_at_a_tty_writes_candidates(deployment, monkeypatch) -> None: + monkeypatch.setattr(_common, "_is_interactive", lambda: True) + result = _run(deployment, "--budget", "500", "--buffer-pct", "0.1", input="Y\n") + assert result.exit_code == 0, result.output + rows = _repo(deployment[0]).get_rules() + assert [(r["kind"], r["status"]) for r in rows] == [("dca", "candidate")] * 3 + assert result.output.count("added rule ") == 3 + # Structure, not substrings: the three rows are exactly the worked example's products, + # each paired with its own per-buy amount. + assert {r["params"]["product_id"] for r in rows} == {"BTC-USD", "ETH-USD", "PAXG-USD"} + expected_budget_by_product = { + "BTC-USD": Decimal("41.39"), + "ETH-USD": Decimal("31.04"), + "PAXG-USD": Decimal("31.04"), + } + actual_budget_by_product = { + r["params"]["product_id"]: Decimal(r["params"]["budget_usd"]) for r in rows + } + assert actual_budget_by_product == expected_budget_by_product + + +def test_no_at_a_tty_writes_nothing(deployment, monkeypatch) -> None: + monkeypatch.setattr(_common, "_is_interactive", lambda: True) + result = _run(deployment, "--budget", "500", "--buffer-pct", "0.1", input="N\n") + assert result.exit_code == 0 + assert _repo(deployment[0]).get_rules() == [] + assert "cancelled" in result.output.lower() + + +def test_edit_renormalises_and_reshows_before_approval(deployment, monkeypatch) -> None: + """[E]: BTC 1, ETH 1, PAXG 0 -> BTC/ETH at 50% each, PAXG excluded, then [Y].""" + monkeypatch.setattr(_common, "_is_interactive", lambda: True) + result = _run(deployment, "--budget", "500", "--buffer-pct", "0.1", input="E\n1\n1\n0\nY\n") + assert result.exit_code == 0, result.output + assert result.output.count("== Schedule ==") == 2 # shown, edited, re-shown + assert "set to 0 in this session" in result.output + rows = _repo(deployment[0]).get_rules() + assert {r["params"]["product_id"] for r in rows} == {"BTC-USD", "ETH-USD"} + assert {Decimal(r["params"]["budget_usd"]) for r in rows} == {Decimal("51.74")} + + +def test_edit_reprompts_on_a_bad_weight_and_keeps_the_valid_one(deployment, monkeypatch) -> None: + """`keel/commands/dca.py:155` -- `_edit_weights`'s `except DcaPlanError` branch had no test. + BTC gets an invalid weight ("-1") first: the loop must re-issue the SAME prompt (`_edit_weights` + stays on BTC, it does not advance to ETH) and echo `parse_weight`'s reason, before accepting + a valid one ("1") and moving on.""" + monkeypatch.setattr(_common, "_is_interactive", lambda: True) + result = _run(deployment, "--budget", "500", "--buffer-pct", "0.1", input="E\n-1\n1\n1\n0\nY\n") + assert result.exit_code == 0, result.output + assert "weight for BTC must be 0 or more, got '-1'" in result.output + # The prompt itself (not the error line, which also contains "weight for BTC") was re-issued + # for BTC, not skipped past to ETH: + assert result.output.count("weight for BTC [") == 2 + assert result.output.count("weight for ETH [") == 1 + rows = _repo(deployment[0]).get_rules() + assert {r["params"]["product_id"] for r in rows} == {"BTC-USD", "ETH-USD"} + # The valid re-entered weight (1, not the rejected -1) is what was actually used: BTC/ETH + # renormalise to 50/50, same per-buy figure `test_edit_renormalises_and_reshows_before_approval` + # pins for the identical 1/1/0 split. + assert {Decimal(r["params"]["budget_usd"]) for r in rows} == {Decimal("51.74")} + + +def test_a_blocked_plan_at_a_tty_does_not_offer_approve(deployment, monkeypatch) -> None: + monkeypatch.setattr(_common, "_is_interactive", lambda: True) + result = _run(deployment, "--budget", "5000", "--buffer-pct", "0", input="Y\nN\n") + assert "[Y] Approve" not in result.output + assert _repo(deployment[0]).get_rules() == [] + assert result.exit_code == 0, result.output + assert result.output.count("[E] Edit weights / [N] Cancel") == 1 + + +def test_bad_inputs_are_click_usage_errors(deployment) -> None: + result = _run(deployment, "--budget", "500", "--buffer-pct", "10") + assert result.exit_code == 2 + assert "0.1 means" in result.output + + +def test_case_colliding_target_weights_are_a_clean_error_not_a_crash( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """#848: a config with `btc:` and `BTC:` both set used to reach `build_dca_plan` uncaught + (`DcaPlanError` was never a `click.ClickException`), crashing with a raw traceback instead of + the clean, verbatim-message error `DcaPlanError`'s own docstring promises.""" + from tests.conftest import VALID_CONFIG_YAML + + db = tmp_path / "t.db" + attest_subscription(_repo(db), now_ts=int(time.time()), free_volume_usd=_ROOMY_CAP) + monkeypatch.setattr(dca_cli, "screen_product", _screen()) + config_path = tmp_path / "config.yaml" + config_path.write_text(VALID_CONFIG_YAML.replace("BTC: 0.40", "btc: 0.40\n BTC: 0.05")) + + result = CliRunner().invoke( + cli, + [ + "--db", + str(db), + "--config", + str(config_path), + "dca", + "plan", + "--budget", + "500", + "--buffer-pct", + "0.1", + ], + ) + assert result.exit_code == 1, result.output + assert result.exception is None or isinstance(result.exception, SystemExit) + assert "Traceback" not in result.output + assert "collide" in result.output + assert "btc" in result.output and "BTC" in result.output + + +def test_an_absurd_budget_is_a_click_usage_error_not_a_crash(deployment) -> None: + """Defect (review of #846): `--budget 1e30` used to reach `Decimal.quantize` and raise + `decimal.InvalidOperation` -- a bare traceback out of the CLI, not `click.BadParameter`.""" + result = _run(deployment, "--budget", "1e30", "--buffer-pct", "0.1") + assert result.exit_code == 2, result.output + assert "InvalidOperation" not in result.output + assert "budget" in result.output.lower() + + +def test_buffer_pct_is_required(deployment) -> None: + result = _run(deployment, "--budget", "500") + assert result.exit_code == 2 + assert "--buffer-pct" in result.output + + +def test_the_disclaimer_is_printed(deployment, monkeypatch) -> None: + monkeypatch.setattr(_common, "_is_interactive", lambda: False) + assert _common.DISCLAIMER in _run(deployment, "--budget", "500", "--buffer-pct", "0.1").output + + +def test_editing_every_weight_to_zero_leaves_edit_or_cancel(deployment, monkeypatch) -> None: + monkeypatch.setattr(_common, "_is_interactive", lambda: True) + result = _run(deployment, "--budget", "500", "--buffer-pct", "0.1", input="E\n0\n0\n0\nN\n") + assert result.exit_code == 0, result.output + assert "no asset is eligible" in result.output + assert _repo(deployment[0]).get_rules() == [] diff --git a/tests/commands/test_dca_plan.py b/tests/commands/test_dca_plan.py new file mode 100644 index 00000000..99e3b6ba --- /dev/null +++ b/tests/commands/test_dca_plan.py @@ -0,0 +1,978 @@ +"""`keel.commands.dca_plan` -- the DCA plan SERVICE (no click). + +Pins, in order: input parsing (percent-shaped and non-finite input refused), rail 14's cap read +exactly as `guards.check` reads it (parity driven through `guards.check` itself), the apply +command's no-exponent spelling, then (Tasks 2-5) the universe, the amounts, the rendering and +the all-or-nothing write. +""" + +from __future__ import annotations + +import ast +import sqlite3 +from decimal import Decimal, InvalidOperation +from pathlib import Path + +import pytest +from keel_core.config import load_config +from keel_core.subscription import SubscriptionStatus + +from keel.commands import dca_plan as dca_mod +from keel.commands.dca_plan import ( + RAIL14_NOTE, + DcaPlanError, + DcaPlanRefused, + ExcludedAsset, + PlanInputs, + apply_command, + apply_dca_plan, + build_dca_plan, + monthly_buy_cap, + parse_plan_inputs, + parse_weight, + render_dca_plan, + select_universe, +) +from keel.compliance.screen import MarketFacts, ScreenResult +from keel.config import Config +from keel.data.db import connect, migrate +from keel.data.repository import Repository +from keel.execution import guards +from tests.conftest import attest_subscription +from tests.execution.test_guards import NOW_TS, _intent, _keys, _roomy_config, _unattested_repo + + +def test_the_service_module_imports_no_click() -> None: + """R13: a pure service. The web layer imports this module; click is a front-end's.""" + tree = ast.parse(Path(dca_mod.__file__).read_text(encoding="utf-8")) + imported = { + alias.name.split(".")[0] + for node in ast.walk(tree) + if isinstance(node, ast.Import) + for alias in node.names + } | { + (node.module or "").split(".")[0] + for node in ast.walk(tree) + if isinstance(node, ast.ImportFrom) + } + assert "click" not in imported + assert "keel" in imported # the scan saw real imports -- it is not vacuous + + +def test_parse_plan_inputs_accepts_a_budget_and_a_fraction() -> None: + assert parse_plan_inputs("500", "0.1") == PlanInputs(Decimal("500"), Decimal("0.1"), 7) + assert parse_plan_inputs(" 500 ", "0", cadence_days=14).cadence_days == 14 + + +@pytest.mark.parametrize( + ("budget", "buffer", "needle"), + [ + ("0", "0.1", "budget must be positive"), + ("-5", "0.1", "budget must be positive"), + ("abc", "0.1", "is not a number"), + ("NaN", "0.1", "not a finite number"), + ("Infinity", "0.1", "not a finite number"), + ("500", "10", "0.1 means"), # percent-shaped: refused with the fraction hint (R11) + ("500", "1", "0.1 means"), # 1 would plan a spend of zero + ("500", "-0.1", "0.1 means"), + ], +) +def test_parse_plan_inputs_refuses_with_a_named_reason( + budget: str, buffer: str, needle: str +) -> None: + with pytest.raises(DcaPlanError) as excinfo: + parse_plan_inputs(budget, buffer) + assert needle in str(excinfo.value) + + +def test_parse_plan_inputs_refuses_a_non_positive_cadence() -> None: + with pytest.raises(DcaPlanError, match="cadence"): + parse_plan_inputs("500", "0.1", cadence_days=0) + + +def test_an_absurd_budget_is_refused_cleanly_not_a_decimal_crash() -> None: + """Defect (review of #846): `--budget 1e30` used to reach `Decimal.quantize` (cents, + 28-digit default context) with more digits than the context allows, raising + `decimal.InvalidOperation` -- a raw traceback, not a usage error. `parse_plan_inputs` must + refuse it before it ever reaches a `quantize` call.""" + with pytest.raises(DcaPlanError, match="budget"): + parse_plan_inputs("1e30", "0.1") + # Not vacuous: unpatched, this is exactly the crash being refused against. + with pytest.raises(InvalidOperation): + Decimal("1e30").quantize(Decimal("0.01")) + + +def test_parse_weight() -> None: + assert parse_weight("0.25", "BTC") == Decimal("0.25") + assert parse_weight("0", "BTC") == Decimal("0") + for bad in ("-1", "x", "NaN"): + with pytest.raises(DcaPlanError, match="BTC"): + parse_weight(bad, "BTC") + + +# -- rail 14's cap, read the way the rail reads it -------------------------------------------- + + +@pytest.mark.parametrize( + ("setup", "expected_cap", "reason_needle"), + [ + ("none", Decimal("0"), "no subscription has been attested"), + ("active", Decimal("500"), ""), + ("suspect", Decimal("0"), "suspect"), + ("lapsed", Decimal("0"), "lapsed"), + ("overdue", Decimal("0"), "overdue"), + ], +) +def test_the_cap_is_the_allowance_rail_14_enforces( + setup: str, expected_cap: Decimal, reason_needle: str +) -> None: + """Parity driven through `guards.check` ITSELF (R3): a buy of exactly the cap passes rail 14 + and a buy one cent over is vetoed by it. A copy that drifted from the rail fails here.""" + repo = _unattested_repo() + if setup != "none": + status = { + "active": SubscriptionStatus.ACTIVE, + "suspect": SubscriptionStatus.SUSPECT, + "lapsed": SubscriptionStatus.LAPSED, + "overdue": SubscriptionStatus.ACTIVE, + }[setup] + attest_subscription( + repo, + now_ts=NOW_TS, + free_volume_usd=Decimal("500"), + status=status, + attest_due_ts=NOW_TS - 1 if setup == "overdue" else None, + ) + config = _roomy_config() + + cap = monthly_buy_cap(repo, config, venue="coinbase", now_ts=NOW_TS) + + assert cap.allowance_usd == expected_cap + assert cap.venue == "coinbase" + assert reason_needle in cap.degraded_reason + assert cap.in_force is (reason_needle == "") + over = guards.check(_intent(notional=expected_cap + Decimal("0.01")), repo, config, NOW_TS) + assert _keys(over) & {"monthly_subscription_allowance", "subscription_unattested"} + if expected_cap > 0: + at = guards.check(_intent(notional=expected_cap), repo, config, NOW_TS) + assert not _keys(at) & {"monthly_subscription_allowance", "subscription_unattested"} + + +def test_an_unlimited_tier_has_no_cap() -> None: + repo = _unattested_repo() + attest_subscription(repo, now_ts=NOW_TS, free_volume_usd=None) + cap = monthly_buy_cap(repo, _roomy_config(), venue="coinbase", now_ts=NOW_TS) + assert cap.allowance_usd is None + assert cap.in_force + + +def test_the_cap_is_read_for_the_venue_asked_about() -> None: + """The DEPLOYMENT'S venue (rail 14's key), never a hardcoded coinbase.""" + repo = _unattested_repo() + attest_subscription(repo, now_ts=NOW_TS, free_volume_usd=Decimal("900"), venue="alpaca") + assert monthly_buy_cap(repo, _roomy_config(), venue="alpaca", now_ts=NOW_TS).allowance_usd == ( + Decimal("900") + ) + assert monthly_buy_cap( + repo, _roomy_config(), venue="coinbase", now_ts=NOW_TS + ).allowance_usd == (Decimal("0")) + + +# -- the command string -------------------------------------------------------------------------- + + +def test_apply_command_never_spells_an_exponent() -> None: + """`Decimal("1e3")` is `1E+3`; a command carrying that would still parse, but a reader copying + it sees a number they did not type. `format(..., "f")` is the only spelling used.""" + inputs = parse_plan_inputs("1e3", "0.10") + assert apply_command(inputs) == "keel dca plan --budget 1000 --buffer-pct 0.10" + + +def test_apply_command_carries_cadence_only_when_not_the_default() -> None: + assert "--cadence-days" not in apply_command(parse_plan_inputs("500", "0.1")) + assert apply_command(parse_plan_inputs("500", "0.1", cadence_days=14)).endswith( + "--cadence-days 14" + ) + + +def test_apply_command_names_the_deployment_before_the_subcommand() -> None: + """R17: global options precede the group, and paths are shell-quoted.""" + command = apply_command( + parse_plan_inputs("500", "0.1"), config_path="/a b/config.yaml", db_path="/x/keel.db" + ) + assert command == ( + "keel --config '/a b/config.yaml' --db /x/keel.db dca plan --budget 500 --buffer-pct 0.1" + ) + + +def test_apply_command_without_inputs_is_the_template() -> None: + assert apply_command(None) == "keel dca plan --budget --buffer-pct " + + +# -- the universe: admitted, weighted, allowlisted, no existing DCA rule ------------------------- + + +def _repo() -> Repository: + conn = connect(":memory:") + migrate(conn) + return Repository(conn) + + +def _screen(*rejected: str): + """A fake `screen_fn` admitting every product except the named ASSETS. Injected exactly as + `build_screen_report` takes it -- no candles or attestations needed to reach the plan.""" + + def screen_fn(repo: Repository, product: str, quote: str) -> tuple[MarketFacts, ScreenResult]: + asset = product.split("-")[0] + facts = MarketFacts( + asset=asset, + daily_bars=2000, + median_daily_volume=Decimal("5000000"), + quotable_in_settlement_currency=True, + product_id=product, + venue="coinbase", + ) + admitted = asset not in rejected + failures = [] if admitted else ["history: 12 bars < 1460"] + return facts, ScreenResult(asset=asset, admitted=admitted, failures=failures) + + return screen_fn + + +def _config(valid_config_path: Path, **overrides) -> Config: + """conftest's VALID_CONFIG_YAML: allowlist BTC/ETH/PAXG, weights .40/.30/.30, taker 0.012 by + default, max_per_order_usd 100, dca.budget_usd 50.""" + from dataclasses import replace + + return replace(load_config(str(valid_config_path)), **overrides) + + +def _insert_dca( + repo: Repository, product: str, status: str, budget: str = "40", dip: str = "0" +) -> int: + return repo.insert_rule( + "dca", + { + "product_id": product, + "cadence_days": 7, + "budget_usd": budget, + "dip_bonus_pct": dip, + "lookback_days": 90, + }, + status=status, + now_ts=NOW_TS, + ) + + +def test_every_admitted_weighted_allowlisted_asset_is_allocated(valid_config_path: Path) -> None: + universe = select_universe(_repo(), _config(valid_config_path), screen_fn=_screen()) + assert [(a.asset, a.product_id, a.weight) for a in universe.allocations] == [ + ("BTC", "BTC-USD", Decimal("0.4")), + ("ETH", "ETH-USD", Decimal("0.3")), + ("PAXG", "PAXG-USD", Decimal("0.3")), + ] + assert universe.excluded == () + assert sum(a.weight for a in universe.allocations) == Decimal("1") + + +def test_a_rejected_asset_is_excluded_with_the_screens_reason_and_weights_renormalise( + valid_config_path: Path, +) -> None: + universe = select_universe(_repo(), _config(valid_config_path), screen_fn=_screen("BTC")) + assert [a.asset for a in universe.allocations] == ["ETH", "PAXG"] + assert [a.weight for a in universe.allocations] == [Decimal("0.5"), Decimal("0.5")] + assert universe.excluded == ( + ExcludedAsset("BTC", ("not_admitted",), "history: 12 bars < 1460"), + ) + + +def test_an_asset_with_no_weight_is_excluded_as_such(valid_config_path: Path) -> None: + config = _config( + valid_config_path, target_weights={"BTC": Decimal("0.5"), "ETH": Decimal("0.5")} + ) + universe = select_universe(_repo(), config, screen_fn=_screen()) + assert [a.asset for a in universe.allocations] == ["BTC", "ETH"] + assert universe.excluded == (ExcludedAsset("PAXG", ("no_weight",), ""),) + + +def test_an_existing_non_disabled_dca_rule_leaves_its_asset_untouched( + valid_config_path: Path, +) -> None: + """Rule 6 on the live account ($/week BTC) is the prime case: listed, unchanged, no new rule.""" + repo = _repo() + rule_id = _insert_dca(repo, "BTC-USD", "live") + _insert_dca(repo, "ETH-USD", "disabled") # disabled does NOT count as existing + + universe = select_universe(repo, _config(valid_config_path), screen_fn=_screen()) + + assert [a.asset for a in universe.allocations] == ["ETH", "PAXG"] + assert universe.excluded == (ExcludedAsset("BTC", ("has_dca_rule",), f"rule {rule_id} (live)"),) + assert [(e.rule_id, e.asset, e.status, e.budget_usd) for e in universe.existing] == [ + (rule_id, "BTC", "live", Decimal("40")) + ] + + +def test_an_existing_rules_dip_bonus_pct_is_carried(valid_config_path: Path) -> None: + """Coordinator amendment: R7 (Task 3) needs each existing row's own `dip_bonus_pct`.""" + repo = _repo() + _insert_dca(repo, "BTC-USD", "live", dip="2") + + universe = select_universe(repo, _config(valid_config_path), screen_fn=_screen()) + + assert universe.existing[0].dip_bonus_pct == Decimal("2") + + +def test_a_stored_rule_missing_budget_usd_is_counted_at_the_rules_default_and_named( + valid_config_path: Path, +) -> None: + """Defect (review of #846): a legacy row whose params lack `budget_usd` used to read as $0, + undercounting a live rule's R7 commitment. `agent.build_rule_from_params` would construct + that row with `Dca.__init__`'s own default, so the plan counts it at THAT default -- read + from the constructor's signature, not a re-typed literal and not a change to the rule class + -- and says so in one named warning, because the figure is inferred, not stored.""" + import inspect + + from keel.commands.dca_plan import existing_dca_rules + from keel.strategy.rules.dca import Dca + + constructor_default = inspect.signature(Dca).parameters["budget_usd"].default + repo = _repo() + rule_id = repo.insert_rule( + "dca", + {"product_id": "BTC-USD", "cadence_days": 7, "dip_bonus_pct": "0", "lookback_days": 90}, + status="live", + now_ts=NOW_TS, + ) + _insert_dca(repo, "SOL-USD", "live", budget="40") # stores budget_usd: no warning for it + + by_id = {rule.rule_id: rule for rule in existing_dca_rules(repo)} + assert by_id[rule_id].budget_usd == constructor_default == Decimal("50") + + # Counted at $50 in R7's commitment, not $0: 50 x 30.4375/7 = 217.41 (down), plus SOL's + # 40 x 30.4375/7 = 173.92 (down). + plan = _plan(valid_config_path, repo) + assert plan.existing_live_monthly_usd == Decimal("217.41") + Decimal("173.92") + named = [w for w in plan.warnings if "no stored budget_usd" in w] + assert len(named) == 1 + assert named[0].startswith(f"rule {rule_id} (BTC-USD) has no stored budget_usd") + + +@pytest.mark.parametrize( + ("cadence", "expected"), + [(1, 31), (2, 16), (7, 5), (10, 4), (14, 3), (28, 2), (30, 2), (31, 1), (45, 1)], +) +def test_worst_month_buy_days_is_the_most_cadence_days_any_calendar_month_holds( + cadence: int, expected: int +) -> None: + """R6 amended (#847): the most `epoch_day % cadence == 0` days any 28-31-day UTC calendar + month can contain -- ceil(31 / cadence) for every cadence up to 31, and 1 beyond.""" + from keel.commands.dca_plan import _worst_month_buy_days + + assert _worst_month_buy_days(cadence) == expected + + +def test_every_reason_that_applies_is_named(valid_config_path: Path) -> None: + repo = _repo() + _insert_dca(repo, "BTC-USD", "candidate") + config = _config(valid_config_path, target_weights={"ETH": Decimal("1")}) + universe = select_universe(repo, config, screen_fn=_screen("BTC")) + btc = next(e for e in universe.excluded if e.asset == "BTC") + assert btc.reasons == ("not_admitted", "no_weight", "has_dca_rule") + + +def test_a_weight_for_an_asset_off_the_allowlist_is_reported_not_dropped( + valid_config_path: Path, +) -> None: + """R4 / Review Focus 5: a weight never vanishes without a line saying why.""" + config = _config( + valid_config_path, + target_weights={ + "BTC": Decimal("0.5"), + "ETH": Decimal("0.3"), + "PAXG": Decimal("0.1"), + "FET": Decimal("0.1"), + }, + ) + universe = select_universe(_repo(), config, screen_fn=_screen()) + assert ExcludedAsset("FET", ("not_on_allowlist",), "") in universe.excluded + + +def test_weight_keys_are_matched_case_insensitively(valid_config_path: Path) -> None: + """Review Focus 5: `btc: 0.4` in YAML is BTC's weight, not an off-allowlist asset.""" + config = _config( + valid_config_path, + target_weights={"btc": Decimal("0.4"), "Eth": Decimal("0.3"), "PAXG": Decimal("0.3")}, + ) + universe = select_universe(_repo(), config, screen_fn=_screen()) + assert [a.asset for a in universe.allocations] == ["BTC", "ETH", "PAXG"] + assert universe.excluded == () + + +def test_a_repeated_allowlist_entry_is_one_asset_not_a_double_share( + valid_config_path: Path, +) -> None: + """#849: `allowlist: [BTC, ETH, btc, PAXG]` passes `load_config` as-is. The allowlist names + assets, so a repeat (in any case) is the SAME asset: one allocation, one buy, one rule -- + never a doubled weight or two BTC-USD candidates. The plan must match the worked example + exactly, as if BTC were listed once.""" + config = _config(valid_config_path, allowlist=("BTC", "ETH", "btc", "PAXG")) + universe = select_universe(_repo(), config, screen_fn=_screen()) + assert [a.asset for a in universe.allocations] == ["BTC", "ETH", "PAXG"] + assert sum(a.weight for a in universe.allocations) == Decimal("1") + assert [asset for asset, _ in universe.editable] == ["BTC", "ETH", "PAXG"] + + plan = _plan(valid_config_path, allowlist=("BTC", "ETH", "btc", "PAXG")) + assert [(b.asset, b.per_buy_usd) for b in plan.buys] == [ + ("BTC", Decimal("41.39")), + ("ETH", Decimal("31.04")), + ("PAXG", Decimal("31.04")), + ] + + +def test_a_non_finite_target_weight_is_refused_cleanly(valid_config_path: Path) -> None: + """A `.nan` / `.inf` weight in YAML passes `load_config`; it must be a `DcaPlanError` naming + the key, not an `InvalidOperation` traceback from the `<= 0` comparison.""" + for bad in ("NaN", "Infinity"): + config = _config(valid_config_path, target_weights={"BTC": Decimal(bad)}) + with pytest.raises(DcaPlanError, match="target_weights.*BTC"): + select_universe(_repo(), config, screen_fn=_screen()) + + +def test_case_colliding_target_weights_are_refused_not_silently_dropped( + valid_config_path: Path, +) -> None: + """#848: `{"btc": .9, "BTC": .1}` uppercase-collide. The old code built `{asset.upper(): w + for asset, w in ...}` over the dict in iteration order, so BTC's weight silently became + whichever key came last (0.1), and the 0.9 vanished with no line saying so -- the exact + silent-drop class #198/R4 exists to prevent for every OTHER kind of dropped weight. This + must refuse loudly instead, naming both colliding keys.""" + config = _config( + valid_config_path, + target_weights={"btc": Decimal("0.9"), "BTC": Decimal("0.1"), "ETH": Decimal("0.5")}, + ) + with pytest.raises(DcaPlanError) as excinfo: + select_universe(_repo(), config, screen_fn=_screen()) + message = str(excinfo.value) + assert "btc" in message + assert "BTC" in message + + +def test_non_colliding_mixed_case_weights_still_work(valid_config_path: Path) -> None: + """The collision guard must not refuse the ordinary case (one spelling per asset) that + `test_weight_keys_are_matched_case_insensitively` already pins -- this is the negative case + for the SAME guard, run through `build_dca_plan` rather than `select_universe` directly.""" + config = _config( + valid_config_path, + target_weights={"btc": Decimal("0.4"), "Eth": Decimal("0.3"), "PAXG": Decimal("0.3")}, + ) + universe = select_universe(_repo(), config, screen_fn=_screen()) + assert [a.asset for a in universe.allocations] == ["BTC", "ETH", "PAXG"] + + +def test_editable_assets_are_admitted_allowlisted_and_without_a_dca_rule( + valid_config_path: Path, +) -> None: + """R14: `[E]` cannot re-admit a rejected asset or stack a rule on an existing one.""" + repo = _repo() + _insert_dca(repo, "BTC-USD", "paper") + universe = select_universe(repo, _config(valid_config_path), screen_fn=_screen("PAXG")) + assert universe.editable == (("ETH", Decimal("0.3")),) + + +def test_a_weights_override_replaces_config_weights_and_zero_excludes( + valid_config_path: Path, +) -> None: + universe = select_universe( + _repo(), + _config(valid_config_path), + screen_fn=_screen(), + weights_override={"BTC": Decimal("1"), "ETH": Decimal("1"), "PAXG": Decimal("0")}, + ) + assert [(a.asset, a.weight) for a in universe.allocations] == [ + ("BTC", Decimal("0.5")), + ("ETH", Decimal("0.5")), + ] + assert ExcludedAsset("PAXG", ("no_weight",), "set to 0 in this session") in universe.excluded + # Edits persist into the next edit's defaults: + assert dict(universe.editable)["PAXG"] == Decimal("0") + + +def test_a_weights_override_for_a_non_editable_asset_is_refused(valid_config_path: Path) -> None: + with pytest.raises(DcaPlanError, match="BTC"): + select_universe( + _repo(), + _config(valid_config_path), + screen_fn=_screen("BTC"), + weights_override={"BTC": Decimal("1")}, + ) + + +def test_selecting_the_universe_writes_nothing(valid_config_path: Path) -> None: + repo = _repo() + before = repo._conn.total_changes # type: ignore[attr-defined] + select_universe(repo, _config(valid_config_path), screen_fn=_screen()) + assert repo._conn.total_changes == before # type: ignore[attr-defined] + + +# -- amounts, fees, the cap check, blockers and warnings (`build_dca_plan`) ----------------------- + + +def replace_caps(valid_config_path: Path, **caps): + from dataclasses import replace + + return replace(load_config(str(valid_config_path)).caps, **caps) + + +def _plan( + valid_config_path: Path, + repo: Repository | None = None, + *, + # #847: 600 clears the worst calendar month's 5 x $103.47 = $517.35 for the default + # BTC/ETH/PAXG .4/.3/.3 weights (or any renormalised subset of them, which sums to the same + # total) -- tests exercising THAT exact defect pass their own `cap` explicitly. + cap: str | None = "600", + rejected: tuple[str, ...] = (), + budget: str = "500", + buffer: str = "0.1", + weights_override=None, + **config_overrides, +): + repo = repo or _repo() + if cap is not None: + attest_subscription(repo, now_ts=NOW_TS, free_volume_usd=Decimal(cap)) + return build_dca_plan( + repo, + _config(valid_config_path, **config_overrides), + parse_plan_inputs(budget, buffer), + venue="coinbase", + now_ts=NOW_TS, + screen_fn=_screen(*rejected), + weights_override=weights_override, + ) + + +def test_the_worked_example(valid_config_path: Path) -> None: + """#847: at the cap this worked example was originally written against ($500), the plan is + now a BLOCKER, not approvable -- see `test_the_worked_examples_worst_month_blocks_the_500_cap` + below for why. The per-buy math this test exists to pin is unchanged.""" + plan = _plan(valid_config_path, cap="500") + assert plan.spend_usd == Decimal("450.00") + assert plan.buffer_usd == Decimal("50.00") + assert [ + (b.asset, b.cadence_days, b.per_buy_usd, b.monthly_usd, b.est_monthly_fee_usd) + for b in plan.buys + ] == [ + ("BTC", 7, Decimal("41.39"), Decimal("179.97"), Decimal("2.16")), + ("ETH", 7, Decimal("31.04"), Decimal("134.96"), Decimal("1.62")), + ("PAXG", 7, Decimal("31.04"), Decimal("134.96"), Decimal("1.62")), + ] + assert plan.buy_count == 3 + assert [b.weight_pct for b in plan.buys] == [Decimal("40.0"), Decimal("30.0"), Decimal("30.0")] + assert plan.planned_monthly_usd == Decimal("449.89") + assert plan.est_monthly_fees_usd == Decimal("5.40") + assert plan.taker_pct == Decimal("0.012") + assert plan.taker_pct_display == Decimal("1.200") + assert not plan.approvable + + +def test_the_worked_examples_worst_month_blocks_the_500_cap(valid_config_path: Path) -> None: + """#847 (defect, review of #846): rail 14 caps the UTC CALENDAR month + (`guards._monthly_buy_spend_usd`), not an average 30.4375-day month. Every DCA rule buys on + the same days (`epoch_day % cadence_days == 0`, `Dca.detect`), so a 7-day cadence's worst + calendar month holds 5 buy days (31-day month, phase aligned on the 1st: days 1/8/15/22/29). + The worked example's per-cycle total is $41.39 + $31.04 + $31.04 = $103.47/week; 5 x $103.47 + = $517.35, which exceeds the $500 cap even though the average-month `spend` ($450) does not.""" + plan = _plan(valid_config_path, cap="500") + assert plan.worst_month_buy_days == 5 + assert plan.worst_month_cycle_usd == Decimal("103.47") + assert plan.worst_month_spend_usd == Decimal("517.35") + assert not plan.approvable + (blocker,) = [b for b in plan.blockers if "rail 14" in b] + assert blocker == ( + "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" + ) + + +def test_a_worst_month_that_fits_the_cap_is_still_approvable(valid_config_path: Path) -> None: + """The other half of #847: the SAME worst-case figures, against a cap that actually clears + them ($520 > $517.35), are not a blocker. Proves the fix compares the worst month correctly + in both directions, not just as a stricter-always veto.""" + plan = _plan(valid_config_path, cap="520") + assert plan.worst_month_buy_days == 5 + assert plan.worst_month_cycle_usd == Decimal("103.47") + assert plan.worst_month_spend_usd == Decimal("517.35") + assert plan.approvable, plan.blockers + + +def test_the_planned_total_never_exceeds_the_spend_that_was_checked( + valid_config_path: Path, +) -> None: + """R5: every rounding step errs toward spending less.""" + for budget in ("37", "101.01", "999.99", "12345"): + plan = _plan(valid_config_path, budget=budget, buffer="0.05", cap=None) + assert plan.planned_monthly_usd <= plan.spend_usd + + +def test_the_minimum_order_size_is_unknown_and_said_so(valid_config_path: Path) -> None: + plan = _plan(valid_config_path) + assert all(b.min_order_usd is None for b in plan.buys) + assert any("minimum order size" in w for w in plan.warnings) + + +def test_spend_over_the_cap_is_a_blocker_naming_it_a_buy_cap(valid_config_path: Path) -> None: + plan = _plan(valid_config_path, cap="400") # spend 450 > 400 + assert not plan.approvable + (blocker,) = [b for b in plan.blockers if "rail 14" in b] + assert "450.00" in blocker and "400" in blocker and "buy cap" in blocker + + +def test_an_unattested_venue_blocks_with_the_attest_command(valid_config_path: Path) -> None: + """Review Focus 2: not "450 exceeds 0" -- the reason and the fix.""" + plan = _plan(valid_config_path, cap=None) + (blocker,) = [b for b in plan.blockers if "rail 14" in b] + assert "no subscription has been attested" in blocker + assert "keel subscription attest --venue coinbase" in blocker + + +def test_an_unlimited_tier_is_never_a_cap_blocker(valid_config_path: Path) -> None: + repo = _repo() + attest_subscription(repo, now_ts=NOW_TS, free_volume_usd=None) + plan = _plan( + valid_config_path, + repo, + cap=None, + budget="100000", + buffer="0", + caps=replace_caps(valid_config_path, max_per_order_usd=Decimal("1000000")), + ) + assert not [b for b in plan.blockers if "rail 14" in b] + + +def test_a_per_buy_that_rounds_to_zero_is_a_named_blocker(valid_config_path: Path) -> None: + """Review Focus 1: named, not a traceback from Dca.__init__ and not a dropped asset.""" + plan = _plan( + valid_config_path, + budget="1", + buffer="0", + weights_override={ + "BTC": Decimal("0.99"), + "ETH": Decimal("0.005"), + "PAXG": Decimal("0.005"), + }, + ) + assert [b.asset for b in plan.buys] == ["BTC", "ETH", "PAXG"] # not dropped + assert any("ETH" in b and "$0.00" in b for b in plan.blockers) + assert not plan.approvable + + +def test_a_per_buy_over_the_per_order_cap_is_a_blocker(valid_config_path: Path) -> None: + """R8: conftest's max_per_order_usd is 100; BTC's per-buy at a 5000 budget is 459.95.""" + plan = _plan(valid_config_path, budget="5000", buffer="0", cap="100000") + assert any("BTC" in b and "max_per_order_usd" in b for b in plan.blockers) + + +def test_an_empty_universe_is_a_blocker(valid_config_path: Path) -> None: + plan = _plan(valid_config_path, rejected=("BTC", "ETH", "PAXG")) + assert plan.buys == () and plan.buy_count == 0 + assert any("no asset is eligible" in b for b in plan.blockers) + + +def test_existing_live_dca_spend_is_warned_against_the_cap_at_each_rules_own_amount( + valid_config_path: Path, +) -> None: + """R7 amended (#843): a live row's commitment is that rule's OWN `budget_usd` -- what the + live executor actually spends per buy since #843 -- not `config.dca.budget_usd`. BTC live + weekly 40: 40 x 30.4375/7 = 173.9285... -> 173.92 (down), the average-month figure still + shown. The WARNING TRIGGER itself is worst-case (#847, for consistency with the blocker): + this plan's own worst month (ETH/PAXG only, since BTC already has a rule) is $517.40, and + BTC's own worst month is 40 x 5 = $200.00; combined $717.40 exceeds the $600 cap used here + (chosen so the plan itself, at $517.40, stays under it and this stays a WARNING, not a + blocker -- rail 14 only ever blocks the order actually placed, not a forecast).""" + repo = _repo() + _insert_dca(repo, "BTC-USD", "live", budget="40") + _insert_dca(repo, "SOL-USD", "candidate", budget="40") # not live: no commitment + plan = _plan(valid_config_path, repo) + assert plan.existing_live_monthly_usd == Decimal("173.92") + assert plan.approvable, plan.blockers + (warning,) = [w for w in plan.warnings if "173.92" in w] + assert "600" in warning + + +def test_two_live_dca_rules_commitments_sum(valid_config_path: Path) -> None: + """R7 amended: each live row's commitment is computed from its own budget_usd, and the + rows sum. 20 x 30.4375/7 = 86.9642... -> 86.96 (down); 173.92 + 86.96 = 260.88.""" + repo = _repo() + _insert_dca(repo, "BTC-USD", "live", budget="40") + _insert_dca(repo, "SOL-USD", "live", budget="20") + plan = _plan(valid_config_path, repo) + assert plan.existing_live_monthly_usd == Decimal("260.88") + + +def test_no_warning_claims_the_executor_sizes_dca_from_config(valid_config_path: Path) -> None: + """R9 withdrawn (#843): the executor sizes each DCA buy from the rule's own size_usd, so a + warning saying it uses config dca.budget_usd would be false about the money path.""" + repo = _repo() + _insert_dca(repo, "BTC-USD", "live") + plan = _plan(valid_config_path, repo) + assert len(plan.warnings) >= 2 # non-vacuous: the min-order note AND the live-commit warning + assert not any("dca.budget_usd" in w for w in plan.warnings) + assert not hasattr(plan, "executor_budget_usd") + + +def test_a_live_dip_bonus_rule_gets_a_named_warning(valid_config_path: Path) -> None: + """R7 amended: a live row's dip bonus is not modelled into the commitment sum (its ceiling + is unbounded in principle), so it gets its own warning instead, naming the rule and its + product so the operator knows which row can spend more than the figure shown.""" + repo = _repo() + rule_id = _insert_dca(repo, "BTC-USD", "live", dip="2") + plan = _plan(valid_config_path, repo) + (warning,) = [w for w in plan.warnings if f"rule {rule_id}" in w] + assert "dip" in warning + + +def test_a_candidate_dip_bonus_rule_gets_no_such_warning(valid_config_path: Path) -> None: + """Only LIVE rows spend real money on a dip bonus -- a candidate row is inert.""" + repo = _repo() + rule_id = _insert_dca(repo, "BTC-USD", "candidate", dip="2") + plan = _plan(valid_config_path, repo) + assert not [w for w in plan.warnings if f"rule {rule_id}" in w] + + +def test_even_daily_pacing_is_stated(valid_config_path: Path) -> None: + repo = _repo() + attest_subscription(repo, now_ts=NOW_TS, free_volume_usd=Decimal("500"), pacing="even_daily") + plan = _plan(valid_config_path, repo, cap=None) + assert any("even_daily" in w for w in plan.warnings) + + +# -- the terminal renderer, with rail 14's wording pinned (Task 4) ------------------------------- + + +def _section(lines: list[str], title: str) -> list[str]: + """The lines under a `== title ==` header, up to the next header.""" + start = lines.index(f"== {title} ==") + rest = lines[start + 1 :] + end = next((i for i, line in enumerate(rest) if line.startswith("== ")), len(rest)) + return [line for line in rest[:end] if line.strip()] + + +def test_the_schedule_has_one_row_per_buy_carrying_its_figures(valid_config_path: Path) -> None: + plan = _plan(valid_config_path) + rows = _section(render_dca_plan(plan), "Schedule") + body = [row for row in rows if not row.lstrip().startswith(("asset", "total"))] + assert len(body) == plan.buy_count == 3 + for row, buy in zip(body, plan.buys, strict=True): + cells = row.split() + assert cells[0] == buy.asset + assert "every 7 days" in row + assert f"${buy.per_buy_usd}" in row and f"${buy.monthly_usd}" in row + (total,) = [row for row in rows if row.lstrip().startswith("total")] + assert "$449.89" in total and "$5.40" in total + + +def test_fees_are_labelled_as_the_configured_rate(valid_config_path: Path) -> None: + text = "\n".join(render_dca_plan(_plan(valid_config_path))) + assert "configured fees.taker_pct 1.2%" in text + + +def test_every_excluded_asset_appears_once_with_its_reasons(valid_config_path: Path) -> None: + repo = _repo() + _insert_dca(repo, "BTC-USD", "live") + plan = _plan(valid_config_path, repo, rejected=("PAXG",)) + rows = _section(render_dca_plan(plan), "Excluded") + assert len(rows) == len(plan.excluded) == 2 + assert {row.split()[0] for row in rows} == {"BTC", "PAXG"} + btc = next(row for row in rows if row.split()[0] == "BTC") + assert "existing, unchanged" in btc + + +def test_existing_rules_are_listed_unchanged(valid_config_path: Path) -> None: + repo = _repo() + rule_id = _insert_dca(repo, "BTC-USD", "live") + rows = _section( + render_dca_plan(_plan(valid_config_path, repo)), "Existing DCA rules (unchanged)" + ) + assert len(rows) == 1 and rows[0].split()[0] == f"[{rule_id}]" + + +def test_an_existing_rules_dip_bonus_is_shown_on_its_row(valid_config_path: Path) -> None: + """Coordinator addition: a live existing rule's row carries its dip bonus when it has one, + and the Notes section still has exactly one line per warning (the dip-bonus warning among + them). `[id]` stays the row's first whitespace cell.""" + repo = _repo() + rule_id = _insert_dca(repo, "BTC-USD", "live", dip="2") + plan = _plan(valid_config_path, repo) + assert len(plan.warnings) >= 2 # min-order note AND the dip-bonus warning, non-vacuous + lines = render_dca_plan(plan) + existing_rows = _section(lines, "Existing DCA rules (unchanged)") + assert len(existing_rows) == len(plan.existing) == 1 + (row,) = existing_rows + assert row.split()[0] == f"[{rule_id}]" + assert "dip_bonus_pct 2" in row + assert len(_section(lines, "Notes")) == len(plan.warnings) + + +def test_blockers_and_warnings_each_get_one_line(valid_config_path: Path) -> None: + plan = _plan(valid_config_path, cap="400") + lines = render_dca_plan(plan) + assert len(_section(lines, "Cannot approve")) == len(plan.blockers) + assert len(_section(lines, "Notes")) == len(plan.warnings) + + +def test_an_approvable_plan_has_no_cannot_approve_section(valid_config_path: Path) -> None: + assert "== Cannot approve ==" not in render_dca_plan(_plan(valid_config_path)) + + +def test_the_rendered_plan_states_rail_14_is_a_buy_cap_and_never_fee_free( + valid_config_path: Path, +) -> None: + lines = render_dca_plan(_plan(valid_config_path)) + assert sum(RAIL14_NOTE in line for line in lines) == 1 + # #847: the output says the WORST calendar month is what was checked against the cap, on + # exactly one line, carrying the plan's own checked figures verbatim. + (checked,) = [line for line in lines if "worst calendar month" in line] + assert ( + checked == " checked against the cap: worst calendar month for a 7-day cadence, " + "5 buy day(s) x $103.47 per cycle = $517.35" + ) + assert not any("fee-free" in line.lower() for line in lines) + assert not any("max_exposure_usd" in line for line in lines) + + +# -- the all-or-nothing `candidate` write (Task 5) ------------------------------------------------ + + +def _rows(repo: Repository) -> list[dict]: + return [dict(row) for row in repo.get_rules()] + + +def test_approval_writes_one_candidate_dca_rule_per_buy_through_rules_add( + valid_config_path: Path, +) -> None: + repo = _repo() + plan = _plan(valid_config_path, repo) + outcomes = apply_dca_plan(repo, _config(valid_config_path), plan, now_ts=NOW_TS) + + rows = _rows(repo) + assert len(outcomes) == len(rows) == plan.buy_count == 3 + for row, buy, outcome in zip(rows, plan.buys, outcomes, strict=True): + assert (row["kind"], row["status"]) == ("dca", "candidate") + assert row["params"]["product_id"] == buy.product_id + assert row["params"]["cadence_days"] == 7 + assert Decimal(row["params"]["budget_usd"]) == buy.per_buy_usd + assert outcome.rule_id == row["id"] and outcome.new_status == "candidate" + # The row round-trips through the agent's own reconstruction (what `rules add` guarantees): + from keel.agent import _build_rule + + rebuilt = _build_rule(rows[0]) + assert rebuilt.params["budget_usd"] == Decimal("41.39") + + +def test_approval_never_touches_an_existing_rule(valid_config_path: Path) -> None: + repo = _repo() + live_id = _insert_dca(repo, "BTC-USD", "live") + other_id = repo.insert_rule( + "turtle_breakout", {"product_id": "ETH-USD"}, status="paper", now_ts=NOW_TS + ) + before = {row["id"]: row for row in _rows(repo)} + + apply_dca_plan(repo, _config(valid_config_path), _plan(valid_config_path, repo), now_ts=NOW_TS) + + after = {row["id"]: row for row in _rows(repo)} + assert after[live_id] == before[live_id] and after[other_id] == before[other_id] + new = [row for rid, row in after.items() if rid not in before] + assert {row["params"]["product_id"] for row in new} == {"ETH-USD", "PAXG-USD"} + assert {row["status"] for row in new} == {"candidate"} + + +def test_a_blocked_plan_writes_nothing(valid_config_path: Path) -> None: + repo = _repo() + plan = _plan(valid_config_path, repo, cap="400") + with pytest.raises(DcaPlanRefused, match="rail 14"): + apply_dca_plan(repo, _config(valid_config_path), plan, now_ts=NOW_TS) + assert _rows(repo) == [] + + +def test_a_dca_rule_written_after_the_preview_refuses_the_whole_approval( + valid_config_path: Path, +) -> None: + """Review Focus 3 / R2: the user's concurrent session adds ETH's DCA rule mid-prompt.""" + repo = _repo() + plan = _plan(valid_config_path, repo) + late = _insert_dca(repo, "ETH-USD", "candidate") + + with pytest.raises(DcaPlanRefused, match="ETH"): + apply_dca_plan(repo, _config(valid_config_path), plan, now_ts=NOW_TS) + assert [row["id"] for row in _rows(repo)] == [late] + + +def test_a_stale_dca_rule_refusal_names_the_late_rules_id(valid_config_path: Path) -> None: + """R2: the refusal names the id of the rule that appeared since the preview, not only the + asset -- an operator staring at two ETH rows needs to know which one is the intruder.""" + repo = _repo() + plan = _plan(valid_config_path, repo) + late = _insert_dca(repo, "ETH-USD", "candidate") + + with pytest.raises(DcaPlanRefused, match=f"rule {late}"): + apply_dca_plan(repo, _config(valid_config_path), plan, now_ts=NOW_TS) + assert [row["id"] for row in _rows(repo)] == [late] + + +def test_a_buy_the_rule_cannot_construct_is_refused_before_any_write( + valid_config_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """R2: pre-validation covers every buy before the first insert.""" + repo = _repo() + plan = _plan(valid_config_path, repo) + from keel import agent + + real = agent.build_rule_from_params + paxg_calls = {"n": 0} + + def refuse_paxg(kind, params): + if params["product_id"] == "PAXG-USD": + paxg_calls["n"] += 1 + raise ValueError("budget_usd must be positive") + return real(kind, params) + + monkeypatch.setattr(agent, "build_rule_from_params", refuse_paxg) + with pytest.raises(DcaPlanRefused, match="PAXG"): + apply_dca_plan(repo, _config(valid_config_path), plan, now_ts=NOW_TS) + assert _rows(repo) == [] + # Non-vacuous: the patched function was actually reached for PAXG's pre-validation call. + assert paxg_calls["n"] == 1 + + +def test_a_locked_database_mid_write_names_the_rows_already_written( + valid_config_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """R2's cost-if-wrong case: `add_rule_row` commits per row, so a row that fails AFTER + pre-validation (a locked database, not something pre-validation catches) leaves earlier + rows written. The refusal names their ids. The patch targets `dca_mod.add_rule_row` -- + the NAME bound into this module's namespace by `from keel.commands.rules import + add_rule_row` -- not `keel.commands.rules.add_rule_row`, which `apply_dca_plan` never + looks up again once that name is bound.""" + repo = _repo() + plan = _plan(valid_config_path, repo) + real = dca_mod.add_rule_row + calls = {"n": 0} + + def flaky(*args, **kwargs): + calls["n"] += 1 + if calls["n"] == 2: + raise sqlite3.OperationalError("database is locked") + return real(*args, **kwargs) + + monkeypatch.setattr(dca_mod, "add_rule_row", flaky) + + with pytest.raises(DcaPlanRefused) as excinfo: + apply_dca_plan(repo, _config(valid_config_path), plan, now_ts=NOW_TS) + + rows = _rows(repo) + assert len(rows) == 1 + assert str(rows[0]["id"]) in str(excinfo.value) + # Non-vacuous: the patch reached the two calls `apply_dca_plan`'s write loop made before + # the refusal (one delegated to the real writer, one raised). + assert calls["n"] == 2 diff --git a/tests/commands/test_service_isolation.py b/tests/commands/test_service_isolation.py index bbbd9970..61107a36 100644 --- a/tests/commands/test_service_isolation.py +++ b/tests/commands/test_service_isolation.py @@ -45,6 +45,8 @@ "keel.commands.brokers", "keel.commands.confirm", "keel.commands.db", + "keel.commands.dca", + "keel.commands.dca_plan", "keel.commands.fetch", "keel.commands.insights", "keel.commands.monitor", diff --git a/tests/test_rail14_is_a_buy_cap.py b/tests/test_rail14_is_a_buy_cap.py index c5893a23..1d490a21 100644 --- a/tests/test_rail14_is_a_buy_cap.py +++ b/tests/test_rail14_is_a_buy_cap.py @@ -138,3 +138,14 @@ def test_the_config_templates_keel_setup_writes_make_no_free_allowance_claim() - assert "always fee-free" not in flat, name assert "no free allowance on advanced trade" in flat, name assert _FEE_NOTE in text, name + + +def test_the_dca_plan_calls_rail_14_a_buy_cap_and_never_fee_free() -> None: + """#836: the plan prints rail 14 as a monthly BUY cap and says it is not a fee waiver.""" + from keel.commands.dca_plan import RAIL14_NOTE + + text = RAIL14_NOTE.lower() + assert "buy cap" in text + assert "not a fee waiver" in text + assert "fee-free" not in text + assert not _claims(RAIL14_NOTE)