diff --git a/keel/commands/dca_plan.py b/keel/commands/dca_plan.py index 5fd33c4d..c50eb12a 100644 --- a/keel/commands/dca_plan.py +++ b/keel/commands/dca_plan.py @@ -59,6 +59,8 @@ #: error. This bound is refused well before that ceiling, at a figure no real monthly DCA budget #: could reach. MAX_BUDGET_USD = Decimal("1e12") +#: The finest input `_decimal` accepts: past this, a value is a typo or a probe, not money. +MAX_DECIMAL_PLACES = 12 class DcaPlanError(ValueError): @@ -79,6 +81,15 @@ def _decimal(raw: str, name: str) -> Decimal: 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") + # The WRITTEN exponent, not `normalize()`d: the default context clamps a tiny value there. + exponent = value.as_tuple().exponent + if isinstance(exponent, int) and exponent < -MAX_DECIMAL_PLACES: + # `format(x, "f")` would spell `1e-10000000` out as ten million digits, into the + # command and the card (review of #850) -- refused here, before anything formats it. + raise DcaPlanError( + f"{name} {raw!r} has more than {MAX_DECIMAL_PLACES} decimal places -- refused before " + "it could be spelled out" + ) return value @@ -646,6 +657,18 @@ def build_dca_plan( ) +def worst_month_text(plan: DcaPlan) -> str: + """What rail 14's cap was checked against (R6, amended #847), as ONE sentence both + front-ends show: the CLI prints it after "checked against the cap:", and `/api/dca-plan` + sends it as the card's `cap_check` display. Built from the plan's own `worst_month_*` fields + verbatim, so neither front-end can show a total other than the one the blocker compared.""" + return ( + f"worst calendar month for a {plan.inputs.cadence_days}-day cadence, " + f"{plan.worst_month_buy_days} buy day(s) x {_usd(plan.worst_month_cycle_usd)} per cycle " + f"= {_usd(plan.worst_month_spend_usd)}" + ) + + 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.""" @@ -659,9 +682,7 @@ def render_dca_plan(plan: DcaPlan) -> list[str]: + ("" 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" checked against the cap: {worst_month_text(plan)}", f" {RAIL14_NOTE}", "", "== Schedule ==", diff --git a/keel/web/api.py b/keel/web/api.py index fb3bc805..9c52c9df 100644 --- a/keel/web/api.py +++ b/keel/web/api.py @@ -783,6 +783,56 @@ def read_rules(cfg: ServeConfig, _query: Query, _state: Any, _now_ts: int) -> di return payload.rules_payload(rows) +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. + + **Every `DcaPlanError` is a 400 `ApiRefusal`, including one `build_dca_plan` raises over the + CONFIG** (`target_weights` keys colliding by case, a non-finite weight). That is this + module's one way of saying "declined, and here is why" -- the CLI refuses the same error as a + clean message rather than a traceback, and a 500 here would read "That report could not be + built" over a message that already names the fix. The card shows the refusal's detail. + """ + 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: + # R17: the template names the served deployment as the filled command does. + return payload.dca_plan_awaiting_payload( + command=apply_command(None, config_path=cfg.config_path, db_path=cfg.db_path) + ) + 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 + ) + except DcaPlanError as exc: + raise ApiRefusal(400, "DCA plan refused", str(exc)) from exc + finally: + close_repo(repo) + return payload.dca_plan_payload( + plan, command=apply_command(inputs, config_path=cfg.config_path, db_path=cfg.db_path) + ) + + def read_venues(cfg: ServeConfig, _query: Query, state: Any, _now_ts: int) -> dict[str, Any]: """Capability declarations (unchanged), plus this deployment's venue readiness (#233 PR4). @@ -1051,6 +1101,9 @@ class ApiRoute: collection="rules", sortable=("id", "kind", "status", "created_at", "promoted_at", "demoted_at"), ), + # The DCA proposal card on /rules. GET only, like every route here, and no collection to sort: + # the schedule is in the order the allowlist names its assets. + "/api/dca-plan": ApiRoute(html_route="/rules", read=read_dca_plan), # #706. `needs_database=False`, and NO `collection`/`sortable`: there is nothing on this page # to sort. A tier table ordered by price is a shopping comparison, and the whole point of the # inversion is that these four rows are not four choices. diff --git a/keel/web/payload.py b/keel/web/payload.py index 43aeedeb..3ed02a56 100644 --- a/keel/web/payload.py +++ b/keel/web/payload.py @@ -121,6 +121,7 @@ if TYPE_CHECKING: # pragma: no cover - typing only from keel.commands.activity import ActivityCycle, ActivityEvent, ActivityFeed from keel.commands.balances import AssetBalanceRow, BalancesReport + from keel.commands.dca_plan import BuyCap, DcaPlan from keel.commands.evidence_matrix import MatrixReport, MatrixRow from keel.commands.gauntlet import GauntletReport, GauntletRow from keel.commands.insights import ( @@ -3644,6 +3645,150 @@ def rules_payload(rows: Sequence[Mapping[str, Any]]) -> dict[str, Any]: return {"rules": [_rule_row_payload(row) for row in rows]} +# -- dca plan (the read-only proposal card on /rules) ---------------------------------------------- + +#: The figures in the card's summary. Both states send every one of them (Rule 3). +_DCA_SUMMARY_KEYS = ("budget", "buffer", "spend", "cap", "planned", "fees") + + +def _dca_summary(plan: DcaPlan | None) -> dict[str, Field]: + if plan is None: + return {key: absent() for key in _DCA_SUMMARY_KEYS} + return { + "budget": money(plan.inputs.budget_usd), + "buffer": money(plan.buffer_usd), + "spend": money(plan.spend_usd), + "cap": _dca_cap_figure(plan.cap), + "planned": money(plan.planned_monthly_usd), + "fees": money(plan.est_monthly_fees_usd), + } + + +def _dca_cap_figure(cap: BuyCap) -> Field: + """Rail 14's allowance as the CLI prints it: a degraded record keeps its (fallback) figure and + says why, `$0.00 (because no subscription has been attested)` -- the service puts that reason + in a blocker only when the worst month exceeds the cap, so the figure must carry it.""" + if cap.allowance_usd is None: + return label("unlimited", state=NEUTRAL) + if cap.in_force: + return money(cap.allowance_usd, state=GOOD) + figure = money(cap.allowance_usd, state=BAD) + return {**figure, "display": f"{figure['display']} (because {cap.degraded_reason})"} + + +def _dca_cap_check_state(cap: BuyCap, worst_month_usd: Decimal) -> str: + """Rule 3's judgement on #847's check, made here: over the cap is `bad`, within it `good`, and + an unlimited tier has nothing to exceed. A comparison, not arithmetic -- the total itself was + computed by the service and is placed verbatim.""" + if cap.allowance_usd is None: + return NEUTRAL + return BAD if worst_month_usd > cap.allowance_usd else GOOD + + +def _dca_cadence(days: int) -> Field: + return label(str(days), display=f"every {days} days") + + +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), + "cap_check": absent(), + "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`, `buy_count` and the `worst_month_*` fields 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). + + `cap_check` is what rail 14's cap was actually compared against (R6, amended #847): the worst + UTC calendar month for the plan's cadence, in `worst_month_text`'s words -- the sentence the + CLI prints after "checked against the cap:" -- with the checked total as its `value`.""" + from keel.commands.dca_plan import RAIL14_NOTE, REASON_TEXT, worst_month_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), + "cap_check": label( + _plain(plan.worst_month_spend_usd), + display=worst_month_text(plan), + state=_dca_cap_check_state(plan.cap, plan.worst_month_spend_usd), + ), + "fee_rate": label( + _plain(plan.taker_pct), + display=_trim(_plain(plan.taker_pct_display)) + "% (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": _dca_cadence(buy.cadence_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[reason] for reason 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": _dca_cadence(rule.cadence_days), + # The CLI's `(dip_bonus_pct X)` marker: such a rule's buys can exceed `per_buy`. + # Presentation-ready, "" when there is none (as `excluded[].detail`). + "dip_bonus": ( + f"dip_bonus_pct {rule.dip_bonus_pct}" if rule.dip_bonus_pct > 0 else "" + ), + } + for rule in plan.existing + ], + "blockers": list(plan.blockers), + "warnings": list(plan.warnings), + } + + # -- venues (#534) ------------------------------------------------------------------------------- diff --git a/keel/web/static/css/keel.css b/keel/web/static/css/keel.css index b372671d..ff3af402 100644 --- a/keel/web/static/css/keel.css +++ b/keel/web/static/css/keel.css @@ -809,6 +809,20 @@ code, .stopped p { margin: 0 0 0.7rem; } .stopped .detail { color: var(--muted); font-size: 0.88rem; } +/* The DCA proposal card's apply command: the one string on that card meant to leave it, for a + * terminal. Boxed and selectable like the recovery command below, never a control. */ +.dca-plan .dca-command { + display: block; + margin: 0.35rem 0 0.6rem; + padding: 0.5rem 0.7rem; + border: 1px solid var(--control-line); + border-radius: 4px; + background: var(--code-bg, rgba(127, 127, 127, 0.12)); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + user-select: all; + overflow-wrap: anywhere; +} + /* How to get in (#634 follow-up): the command that RECOVERS the address, set apart from the * detail prose rather than appended to it. The paragraph beside it runs five sentences, and a * command added as a sixth is a command nobody sees -- which is how two people who knew this diff --git a/keel/web/static/js/main.js b/keel/web/static/js/main.js index 6b3bb636..a5189f1d 100644 --- a/keel/web/static/js/main.js +++ b/keel/web/static/js/main.js @@ -112,7 +112,7 @@ const ROUTES = [ { name: "timeline", label: "Timeline", endpoints: ["timeline"] }, { name: "insights", label: "Insights", endpoints: ["insights", "journal"] }, { name: "research", label: "Research", endpoints: ["research/trials", "research/gauntlet", "research/slippage", "research/matrix"] }, - { name: "rules", label: "Rules", endpoints: ["rules"] }, + { name: "rules", label: "Rules", endpoints: ["rules", "dca-plan"] }, { name: "venues", label: "Venues", endpoints: ["venues"] }, { name: "gates", label: "Gates", endpoints: ["gates"] }, { name: "plans", label: "Plans", endpoints: ["plans"] }, @@ -250,6 +250,18 @@ function paramsFor(endpoint) { return fresh; } +// 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 controls, so the address is the one input it takes (R16). Copied +// once, at load, into that endpoint's query bag only -- the rule table's own `?sort=` stays out +// of the address, per `params`' note. +{ + const page = new URLSearchParams(window.location.search); + for (const name of ["budget", "buffer"]) { + const given = page.get(name); + if (given !== null) paramsFor("dca-plan")[name] = given; + } +} + /** * Which section each sectioned view is showing (#773). * @@ -546,7 +558,19 @@ function mount(route, readings) { matrix ? matrix.data : null, ); } - if (route.name === "rules") return rulesView(data, primary.sort, onSort); + if (route.name === "rules") { + // The DCA proposal is SECONDARY, like the journal on `/insights`: a refused read (a malformed + // `?budget=`) leaves the rule ledger standing, and the card is handed the reading's `error` + // so it can say why rather than only that it could not read. + const planReading = readings[1]; + return rulesView( + data, + primary.sort, + onSort, + planReading ? planReading.data : null, + planReading ? planReading.error : null, + ); + } if (route.name === "venues") return venuesView(data, primary.sort, onSort); if (route.name === "gates") return gatesView(data); if (route.name === "plans") return plansView(data); diff --git a/keel/web/static/js/render.js b/keel/web/static/js/render.js index 2f99d838..cac27c2e 100644 --- a/keel/web/static/js/render.js +++ b/keel/web/static/js/render.js @@ -3197,12 +3197,16 @@ function gateCard(rule) { * are a disclosure of `key = value` rows instead, because `_rule_row_payload` stringifies each * value individually and a `repr` of a dict is a thing only a Python programmer can read. * + * Below the ledger sits the DCA proposal card (`dcaPlanCard`), from the page's second endpoint. + * * @param {any} data `/api/rules`'s `data`. * @param {any} sort * @param {(column: string, direction: string) => void} onSort + * @param {any} [plan] `/api/dca-plan`'s `data`, or `null` when that read failed. + * @param {any} [planError] that read's `error`, or `null`. * @returns {DocumentFragment} */ -export function rulesView(data, sort, onSort) { +export function rulesView(data, sort, onSort, plan, planError) { const fragment = document.createDocumentFragment(); fragment.append(el("h1", undefined, "Rules")); fragment.append(el("p", "sub", "read-only · promotion happens in the CLI")); @@ -3245,6 +3249,7 @@ export function rulesView(data, sort, onSort) { ); for (const row of parameterised) fragment.append(paramsCard(row)); } + fragment.append(dcaPlanCard(plan || null, planError || null)); return fragment; } @@ -3270,6 +3275,168 @@ function paramsCard(row) { return node; } +/** + * The DCA proposal (`/api/dca-plan`), as a card under the rules ledger. + * + * **Read-only, and structurally so.** There is no control on it: it builds no button, anchor or + * field to type in, and its one `table()` is drawn without a sort pair. The only way to apply the + * plan is the CLI command it shows, run 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. A refused read (a malformed budget, a config the service refuses) leaves the rules + * ledger standing and places the server's reason here, in the service's own words. + * + * "checked against the cap" is #847's figure: the worst UTC calendar month for the plan's + * cadence, which is what rail 14's cap was actually compared with -- the same sentence the CLI + * prints, written once by `dca_plan.worst_month_text`. + * + * @param {any} plan `/api/dca-plan`'s `data`, or `null` when that read failed. + * @param {any} error that read's `error`, or `null`. + * @returns {HTMLElement} + */ +export function dcaPlanCard(plan, error) { + const card = el("section", "card dca-plan"); + card.append(heading("h-dca-plan", "DCA plan proposal")); + if (!plan) { + card.append(el("p", "note", "The DCA proposal could not be read.")); + if (error) card.append(el("p", "note bad", plain(error.detail))); + return card; + } + const stateLine = el("p"); + stateLine.append(field(plan.state)); + card.append(stateLine); + 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("checked against the cap", plan.cap_check), + 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(el("p", "note", plain(plan.cap_note))); + card.append(dcaPlanRows(plan)); + card.append( + dcaPlanList( + "Excluded", + (plan.excluded || []).map( + /** @param {any} item */ (item) => + [plain(item.asset), plain(item.reasons), plain(item.detail)] + .filter((part) => part !== "") + .join(" — "), + ), + "Nothing excluded.", + ), + ); + card.append( + dcaPlanList( + "Existing DCA rules (unchanged)", + (plan.existing || []).map( + /** @param {any} rule */ (rule) => { + const line = el("span"); + line.append( + ["rule", plain(rule.rule_id), plain(rule.product_id), plain(rule.status)].join(" "), + " · ", + field(rule.per_buy), + " ", + field(rule.cadence), + ); + // The CLI's `(dip_bonus_pct X)` marker: this rule's buys can exceed `per_buy`. + const bonus = plain(rule.dip_bonus); + if (bonus !== "") line.append(" (", bonus, ")"); + return line; + }, + ), + "None.", + ), + ); + card.append(dcaPlanList("Cannot approve", plan.blockers || [], "Nothing blocks it.")); + card.append(dcaPlanList("Notes", plan.warnings || [], "None.")); + card.append(el("h3", undefined, "To apply, at a terminal:")); + card.append(el("code", "dca-command", 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"); + const key = el("span", "k", label); + const holder = el("span", "v"); + holder.append(field(value)); + wrap.append(key, holder); + return wrap; +} + +/** + * The per-asset schedule, one row per planned buy, in the order the service planned them. + * + * @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 buys planned.", + ); +} + +/** + * A small heading over a bullet list -- the card's four lists. Each item is a string the server + * wrote or a node already built from one; either is appended as it is. + * + * @param {string} title + * @param {any[]} items + * @param {string} empty + * @returns {HTMLElement} + */ +function dcaPlanList(title, items, empty) { + const wrap = el("div"); + wrap.append(el("h3", undefined, title)); + if (items.length === 0) { + wrap.append(el("p", "empty", empty)); + return wrap; + } + const list = el("ul"); + for (const item of items) { + const entry = el("li"); + entry.append(item); + list.append(entry); + } + wrap.append(list); + return wrap; +} + /** * Venues: `/api/venues`'s payload. * diff --git a/tests/commands/test_dca_plan.py b/tests/commands/test_dca_plan.py index 99e3b6ba..ea1595a4 100644 --- a/tests/commands/test_dca_plan.py +++ b/tests/commands/test_dca_plan.py @@ -90,6 +90,22 @@ def test_parse_plan_inputs_refuses_a_non_positive_cadence() -> None: parse_plan_inputs("500", "0.1", cadence_days=0) +@pytest.mark.parametrize(("budget", "buffer"), [("1e-10000000", "0.1"), ("500", "1e-10000000")]) +def test_an_input_with_an_absurd_exponent_is_refused_before_it_is_spelled_out( + budget: str, buffer: str +) -> None: + """Review of #850: `format(x, "f")` spells `1e-10000000` out as ten million digits, into the + command and the card's `summary.budget` -- a 25-character query answered with 10-20 MB of + JSON. Refused at the one shared parse, so the CLI and the card refuse it alike.""" + with pytest.raises(DcaPlanError, match="decimal places"): + parse_plan_inputs(budget, buffer) + + +def test_twelve_decimal_places_are_still_accepted() -> None: + inputs = parse_plan_inputs("500.000000000001", "0.000000000001") + assert format(inputs.buffer_pct, "f") == "0.000000000001" + + 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 @@ -821,6 +837,21 @@ def test_blockers_and_warnings_each_get_one_line(valid_config_path: Path) -> Non assert len(_section(lines, "Notes")) == len(plan.warnings) +def test_the_worst_month_sentence_is_one_text_shared_by_the_cli_and_the_card( + valid_config_path: Path, +) -> None: + """#847's cap-check figure is shown by two front-ends: the CLI's line and the web card. ONE + service function writes the sentence, so the card cannot word (or total) it differently from + the line the terminal prints.""" + plan = _plan(valid_config_path) + text = dca_mod.worst_month_text(plan) + assert text == ( + "worst calendar month for a 7-day cadence, 5 buy day(s) x $103.47 per cycle = $517.35" + ) + lines = render_dca_plan(plan) + assert lines.count(" checked against the cap: " + text) == 1 + + 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)) diff --git a/tests/web/test_api.py b/tests/web/test_api.py index a934dda6..ea365409 100644 --- a/tests/web/test_api.py +++ b/tests/web/test_api.py @@ -68,6 +68,7 @@ "/api/research/gauntlet", "/api/research/matrix", "/api/rules", + "/api/dca-plan", "/api/venues", "/api/gates", "/api/plans", @@ -1559,3 +1560,129 @@ def test_no_reader_reads_the_clock_a_second_time() -> None: offenders = {name: calls for name, calls in _reader_clock_calls().items() if calls} assert offenders == {}, "these readers ignore the clock they were handed: " + repr(offenders) + + +# -- /api/dca-plan: the read-only DCA proposal on /rules ------------------------------------------- + + +def _admit_everything(monkeypatch: pytest.MonkeyPatch) -> list[str]: + """Patch THE admission screen at its module, recording each product it is asked about. The + reader imports `screen_product` per call, so the patch reaches it -- and the returned call log + is what proves that it did (an unreached patch would leave the log empty).""" + from keel.commands import assets + from tests.commands.test_dca_plan import _screen + + calls: list[str] = [] + fake = _screen() + + def screen_fn(repo: Any, product: str, quote: str) -> Any: + calls.append(product) + return fake(repo, product, quote) + + monkeypatch.setattr(assets, "screen_product", screen_fn) + return calls + + +def test_dca_plan_without_a_budget_answers_the_awaiting_state( + running: web_server.ServeConfig, +) -> None: + status, _headers, document = _json(running, "/api/dca-plan") + assert status == 200 + data = document["data"] + assert data["state"]["value"] == "awaiting_budget" + # R17: the template names the served deployment too -- filled in and run elsewhere, it must + # still plan against THIS config and database, never the default one. + command = data["command"] + assert command.startswith("keel --config ") + assert command.endswith(" dca plan --budget --buffer-pct ") + assert command.split().count("--config") == 1 and command.split().count("--db") == 1 + assert running.config_path in command and running.db_path in command + assert data["buys"] == [] + + +@pytest.mark.parametrize( + ("query", "needle"), + [ + ("budget=abc&buffer=0.1", "is not a number"), + ("budget=500&buffer=10", "0.1 means"), + ("budget=500", "buffer is required"), + ("budget=NaN&buffer=0", "not a finite number"), + ("budget=-5&buffer=0.1", "budget must be positive"), + ], +) +def test_dca_plan_refuses_bad_input_with_a_400( + running: web_server.ServeConfig, query: str, needle: str +) -> None: + """R15: refused, never guessed -- and in the service's own words, so the CLI and the card + refuse the same input the same way.""" + status, _headers, document = _json(running, "/api/dca-plan?" + query) + assert status == 400, document + assert document["error"]["status"] == "400" + assert needle in document["error"]["detail"] + + +def test_dca_plan_with_a_budget_serves_the_services_plan( + running: web_server.ServeConfig, monkeypatch: pytest.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.""" + calls = _admit_everything(monkeypatch) + status, _headers, document = _json(running, "/api/dca-plan?budget=1e3&buffer=0.1") + assert status == 200, document + assert calls, "the patched screen was never called -- the reader bound its own copy" + data = document["data"] + assert data["summary"]["spend"]["value"] == "900.00" + assert [row["asset"] for row in data["buys"]] == ["BTC", "ETH", "PAXG"] + # The fixture deployment has no attested subscription: the cap is the unsubscribed $0, so the + # plan is blocked, and the worst-month check says what it compared. + assert data["state"]["value"] == "blocked" + assert data["cap_check"]["display"].startswith("worst calendar month for a 7-day cadence") + assert data["cap_check"]["state"] == "bad" + # R17 + Review Focus 4: the exact command, no exponent, naming the served deployment first. + command = data["command"] + assert command.endswith(" dca plan --budget 1000 --buffer-pct 0.1") + assert command.startswith("keel --config ") + assert command.split().count("--config") == 1 and command.split().count("--db") == 1 + assert running.config_path in command and running.db_path in command + + +def test_dca_plan_turns_a_config_refusal_into_a_400_naming_it( + running: web_server.ServeConfig, monkeypatch: pytest.MonkeyPatch +) -> None: + """A `DcaPlanError` from `build_dca_plan` itself -- `target_weights` keys colliding by case + (#848) -- is refused with the service's message, as the CLI refuses it, never a 500 traceback + and never a plan built from a silently dropped weight.""" + _admit_everything(monkeypatch) + config = Path(running.config_path) + text = config.read_text() + assert " BTC: 0.40\n" in text # the edit below lands on the fixture's own weights block + config.write_text(text.replace(" BTC: 0.40\n", " BTC: 0.40\n btc: 0.10\n", 1)) + status, _headers, document = _json(running, "/api/dca-plan?budget=500&buffer=0.1") + assert status == 400, document + assert "collide once uppercased" in document["error"]["detail"] + + +def test_dca_plan_writes_nothing( + running: web_server.ServeConfig, monkeypatch: pytest.MonkeyPatch +) -> None: + calls = _admit_everything(monkeypatch) + status, _headers, _document = _json(running, "/api/dca-plan?budget=500&buffer=0.1") + assert status == 200 and calls + conn = sqlite3.connect(running.db_path) + try: + assert conn.execute("SELECT COUNT(*) FROM rules").fetchone()[0] == 0 + finally: + conn.close() + + +def test_dca_plan_answers_no_post(running: web_server.ServeConfig) -> None: + """GET only: the card's write path is the CLI at a terminal. A POST with the client header + is refused like every other `/api/*` read.""" + status, _headers, _body = _get( + running, + "/api/dca-plan?budget=500&buffer=0.1", + method="POST", + cookie=_session(running), + headers={"X-Keel-Client": "1"}, + ) + assert status == 404 diff --git a/tests/web/test_client_assets.py b/tests/web/test_client_assets.py index 71324937..8a6b59c2 100644 --- a/tests/web/test_client_assets.py +++ b/tests/web/test_client_assets.py @@ -1298,6 +1298,9 @@ def _status_view_keys() -> list[str]: ("researchView", "gauntlet", "/api/research/gauntlet"), ("researchView", "slippage", "/api/research/slippage"), ("rulesView", "data", "/api/rules"), + # The DCA proposal card on /rules. Read bare, the endpoint answers its awaiting shape, which + # carries every key the ready shape does (`payload.dca_plan_awaiting_payload`). + ("dcaPlanCard", "plan", "/api/dca-plan"), ("venuesView", "data", "/api/venues"), ("gatesView", "data", "/api/gates"), ) @@ -1768,6 +1771,10 @@ def _row_reads(view: str) -> dict[tuple[str, str], set[str]]: return found +#: `/api/dca-plan` with the inputs the card takes from the page address. Bare, it answers the +#: awaiting state, whose collections are empty by design. +_DCA_PLAN_ASKED = "/api/dca-plan?budget=500&buffer=0.1" + #: Which endpoint each mapped collection comes from, and how to give it a row. #: #: Hand-written like `_VIEW_ENDPOINTS` beside it, and for the same reason: a table derived from @@ -1787,6 +1794,12 @@ def _row_reads(view: str) -> dict[tuple[str, str], set[str]]: # `/api/journal`'s `notes`, and the seeder writes one entry through the repository because the # CLI writer refuses to run without a terminal. ("insightsView", "notes", "notes.entries", "/api/journal", "journal"), + # The DCA card's three collections. The endpoint is asked WITH a budget, and the `dca_plan` + # seeder admits BTC, rejects PAXG and gives ETH an existing DCA rule -- so each collection has + # a row, and each row the card maps is one the served payload actually built. + ("dcaPlanCard", "plan", "buys", _DCA_PLAN_ASKED, "dca_plan"), + ("dcaPlanCard", "plan", "excluded", _DCA_PLAN_ASKED, "dca_plan"), + ("dcaPlanCard", "plan", "existing", _DCA_PLAN_ASKED, "dca_plan"), ) #: Mapped collections this test does NOT cover, each with the reason. Named rather than omitted: @@ -1849,10 +1862,24 @@ def _seed_ledger(tmp_path: Path) -> None: ) -def _seed_for(kind: str, db_path: str) -> None: +def _seed_for(kind: str, db_path: str, monkeypatch: pytest.MonkeyPatch) -> None: from tests.web.test_api import _seed_orders, _seed_positions, _seed_rules - if kind == "gauntlet": + if kind == "dca_plan": + # The admission screen, patched at its module (the reader resolves it per call), plus + # one existing `dca` rule written through the repository -- the card's three + # collections then each hold a row. + from keel.commands import assets + from keel.data.db import connect, migrate + from keel.data.repository import Repository + from tests.commands.test_dca_plan import _insert_dca, _screen + + monkeypatch.setattr(assets, "screen_product", _screen("PAXG")) + conn = connect(db_path) + migrate(conn) + _insert_dca(Repository(conn), "ETH-USD", "candidate") + conn.close() + elif kind == "gauntlet": _seed_ledger(Path(db_path).parent) elif kind == "positions": _seed_positions(db_path, (("BTC-USD", "0.01", "50000"),)) @@ -1922,13 +1949,14 @@ def test_every_row_key_a_view_reads_is_a_key_its_endpoint_sends( endpoint: str, seed: str, running, # type: ignore[no-untyped-def] + monkeypatch: pytest.MonkeyPatch, ) -> None: """The check `_view_keys` declares out of scope, done one level down. Reverting `row.fallback` to `row.priced_from` in `slippageSection` -- the rename #725 made on both sides -- passes the whole web suite without this test and fails here. """ - _seed_for(seed, running.db_path) + _seed_for(seed, running.db_path, monkeypatch) status, _headers, body = _request(running, endpoint, cookie=_session(running)) assert status == 200, endpoint @@ -1958,6 +1986,25 @@ def test_every_row_key_a_view_reads_is_a_key_its_endpoint_sends( assert not missing, f"{view} reads {sorted(missing)}; {endpoint} does not send them" +def test_the_dca_card_places_every_key_an_existing_rule_row_sends( + running, # type: ignore[no-untyped-def] + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The reverse of the row-key scan, for the existing-rules list: a key the served row sends + and the card never reads fails here. Review of #850: `dip_bonus` -- the CLI's + `(dip_bonus_pct X)` marker -- was on neither side.""" + _seed_for("dca_plan", running.db_path, monkeypatch) + status, _headers, body = _request(running, _DCA_PLAN_ASKED, cookie=_session(running)) + assert status == 200 + rows = json.loads(body)["data"]["existing"] + assert len(rows) == 1, rows # the seeder's ETH rule reached the served payload + sent = set(rows[0]) + assert "dip_bonus" in sent and len(sent) >= 6, sorted(sent) + reads = _row_reads("dcaPlanCard").get(("plan", "existing"), set()) + assert reads, "the scan found no existing-row reads -- it would pass against any payload" + assert sent == reads, f"sent but never placed: {sorted(sent - reads)}" + + def test_every_mapped_collection_is_either_checked_or_named() -> None: """No mapped collection may be silently uncovered. @@ -2401,3 +2448,136 @@ def test_the_redeclaration_scan_can_fail() -> None: "function f() {\n for (const c of a) {\n }\n for (const c of b) {\n }\n}\n" "function g() {\n const note = a;\n}\n" ) + + +# -- the DCA proposal card on /rules (read-only) -------------------------------------------------- +# +# The card's refusal is a NEGATIVE -- no control that could apply the plan -- so, like the chip, +# the banner and the plans view above, it is pinned against the parsed function bodies: tags on a +# closed list, one `code` element paired with `plan.command`, and every key the endpoint sends +# paired with a read of it. + +#: Every function that builds part of the card. The scans below read their bodies together. +_DCA_CARD_FUNCTIONS = ("dcaPlanCard", "dcaPlanFigure", "dcaPlanRows", "dcaPlanList") + +#: 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 _dca_card_bodies() -> str: + source = _source("render.js") + return "\n".join(_function_body(source, name) for name in _DCA_CARD_FUNCTIONS) + + +def test_the_dca_card_builds_only_non_interactive_elements() -> None: + """Structure, not substrings: every `el(""` the card makes is on a closed list, the list + holds nothing a reader can activate, and its one table is drawn without a sort pair.""" + body = _dca_card_bodies() + tags = re.findall(r'\bel\("([a-z0-9]+)"', body) + assert len(tags) >= 8, f"the scan found {len(tags)} elements -- it would pass any card" + assert set(tags) <= _DCA_CARD_TAGS, sorted(set(tags) - _DCA_CARD_TAGS) + assert len(re.findall(r"\btable\(", body)) == 1, "the schedule is the card's one 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_scan_would_see_a_button() -> None: + """The premise of the scan above: a button added to the card's source is found by it.""" + mutated = _source("render.js").replace( + "export function dcaPlanCard(plan, error) {", + 'export function dcaPlanCard(plan, error) {\n el("button", undefined, "Apply");', + 1, + ) + assert mutated != _source("render.js"), "the mutation did not apply" + tags = set(re.findall(r'\bel\("([a-z0-9]+)"', _function_body(mutated, "dcaPlanCard"))) + assert "button" in tags and not tags <= _DCA_CARD_TAGS + + +def test_the_dca_card_places_the_command_in_exactly_one_code_element() -> None: + """The pairing: one `code` element in the whole card, and it is filled from `plan.command`.""" + body = _dca_card_bodies() + assert len(re.findall(r'\bel\("code"', body)) == 1 + paired = re.findall(r'\bel\("code", "[a-z-]+", plain\(plan\.command\)\)', body) + assert len(paired) == 1, "the one code element is not the one filled from plan.command" + + +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 -- the summary's figures one level down, including the worst-month check.""" + status, _headers, body = _request(running, "/api/dca-plan", cookie=_session(running)) + assert status == 200 + data = json.loads(body)["data"] + keys = set(data) + summary = set(data["summary"]) + assert "cap_check" in keys and len(keys) >= 10, sorted(keys) + assert len(summary) >= 6, sorted(summary) + reads = set(_view_keys("dcaPlanCard", "plan")) + for key in sorted(keys - {"summary"}): + assert any(read == key or read.startswith(key + ".") for read in reads), ( + f"/api/dca-plan sends {key}; the card never places it" + ) + for key in sorted(summary): + assert "summary." + key in reads, f"/api/dca-plan sends summary.{key}; never placed" + + +def test_the_dca_card_labels_the_worst_month_check_as_what_the_cap_was_checked_against() -> None: + """#847: the figure beside the cap is the one the blocker compared, and the card says so in + the CLI's own words. Paired: exactly one figure reads `plan.cap_check`, under that label.""" + figures = re.findall(r'dcaPlanFigure\("([^"]+)", (plan\.[a-z_.]+)\)', _dca_card_bodies()) + assert len(figures) >= 8, figures + checks = [label for label, read in figures if read == "plan.cap_check"] + assert checks == ["checked against the cap"], figures + + +def test_the_dca_card_states_a_failed_read_with_the_servers_reason() -> None: + """A 400 (a bad `?budget=`, a config refusal) leaves the rules table standing and says why: + the card is handed the reading's `error` and places its `detail`, the service's own words.""" + body = _function_body(_source("render.js"), "dcaPlanCard") + assert body.count("plain(error.detail)") == 1 + assert "if (!plan)" in body + + +def test_the_rules_view_places_the_dca_card_exactly_once() -> None: + source = _source("render.js") + rules = _function_body(source, "rulesView") + assert rules.count("dcaPlanCard(") == 1 + assert "dcaPlanCard(plan || null, planError || null)" in rules + # And nowhere else: one card, on one page. + assert _code_only(source).count("dcaPlanCard(") == 2 # the declaration and the one call + + +def test_the_rules_view_reads_the_dca_plan_as_its_second_endpoint() -> None: + """The ROUTES row, which `_js_route_names` parses and + `test_the_python_and_javascript_route_tables_agree` pairs with the server's table.""" + source = _source("main.js") + start = source.index("const ROUTES = [") + table_src = source[start : source.index("];", start)] + rows = [line.strip() for line in table_src.splitlines() if 'name: "rules"' in line] + assert rows == ['{ name: "rules", label: "Rules", endpoints: ["rules", "dca-plan"] },'] + + +def test_mount_hands_the_rules_view_the_dca_reading_data_and_error() -> None: + """The secondary reading's `data` AND `error` both reach the view -- `data` alone would turn + a 400 into a bare "could not be read" with the reason thrown away.""" + code = _code_only(_source("main.js")) + calls = re.findall(r"rulesView\(([^;]*)\);", code) + assert len(calls) == 1, calls + args = re.sub(r"\s+", " ", calls[0]).strip().rstrip(",").strip() + assert args == ( + "data, primary.sort, onSort, planReading ? planReading.data : null, " + "planReading ? planReading.error : null" + ) + assert code.count("const planReading = readings[1];") == 1 + + +def test_the_page_address_seeds_only_the_dca_plan_query() -> None: + """R16: the card's inputs come from the page's own address, copied once into the + `dca-plan` endpoint's query bag only -- the names are a closed pair, and nothing else in the + client reads `location.search`.""" + code = _comments_stripped(_source("main.js")) + assert code.count("window.location.search") == 1 + names = re.findall(r"for \(const name of \[([^\]]*)\]\)", code) + assert names == ['"budget", "buffer"'], names + assert code.count('paramsFor("dca-plan")') == 1 diff --git a/tests/web/test_payload.py b/tests/web/test_payload.py index 6aa6a1c2..f1b1944c 100644 --- a/tests/web/test_payload.py +++ b/tests/web/test_payload.py @@ -1940,3 +1940,213 @@ def test_a_planned_tier_is_unknown_and_never_a_warning() -> None: planned = [row["status"] for row in rows if row["status"]["value"] == "planned"] assert len(planned) == 3 assert {status["state"] for status in planned} == {"unknown"} + + +# -- the DCA plan card (`/api/dca-plan`, read-only) ----------------------------------------------- + +_DCA_KEYS = { + "state", + "command", + "summary", + "cap_check", + "fee_rate", + "cap_note", + "buys", + "buy_count", + "excluded", + "existing", + "blockers", + "warnings", +} + + +def _dca_plan(valid_config_path: Path, *, cap: str | None = "600", rejected: tuple[str, ...] = ()): + """A real `build_dca_plan` over conftest's VALID_CONFIG_YAML (BTC/ETH/PAXG at .4/.3/.3), + through the service's own test helpers -- the payload is checked against figures the SERVICE + computed, never against literals restated here.""" + from keel.commands.dca_plan import build_dca_plan, parse_plan_inputs + from tests.commands.test_dca_plan import NOW_TS as DCA_NOW_TS + from tests.commands.test_dca_plan import _config, _repo, _screen + from tests.conftest import attest_subscription + + repo = _repo() + if cap is not None: + attest_subscription( + repo, + now_ts=DCA_NOW_TS, + free_volume_usd=None if cap == "unlimited" else Decimal(cap), + ) + return build_dca_plan( + repo, + _config(valid_config_path), + parse_plan_inputs("500", "0.1"), + venue="coinbase", + now_ts=DCA_NOW_TS, + screen_fn=_screen(*rejected), + ) + + +def test_the_dca_payload_has_one_shape_in_both_states(valid_config_path: Path) -> None: + """Rule 3: a client reads one shape and never branches on which state produced it.""" + from keel.commands.dca_plan import apply_command + + ready = payload.dca_plan_payload(_dca_plan(valid_config_path), command="keel dca plan ...") + awaiting = payload.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["state"]["state"] == "unknown" + assert awaiting["buys"] == [] and awaiting["summary"]["spend"]["state"] == "unknown" + assert awaiting["cap_check"]["state"] == "unknown" + + +def test_the_dca_payload_places_every_figure_the_plan_computed(valid_config_path: Path) -> None: + from keel.commands.dca_plan import RAIL14_NOTE, REASON_TEXT + + plan = _dca_plan(valid_config_path, rejected=("PAXG",)) + body = payload.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" + # Pairings, row by row, against the plan's own buys -- never a count alone. + assert [row["asset"] for row in body["buys"]] == [b.asset for b in plan.buys] == ["BTC", "ETH"] + for row, buy in zip(body["buys"], plan.buys, strict=True): + assert row["product_id"] == buy.product_id + assert row["per_buy"]["value"] == format(buy.per_buy_usd, "f") + assert row["monthly"]["value"] == format(buy.monthly_usd, "f") + assert row["fee"]["value"] == format(buy.est_monthly_fee_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"] == format(plan.spend_usd, "f") == "450.00" + assert body["summary"]["planned"]["value"] == format(plan.planned_monthly_usd, "f") + assert body["summary"]["fees"]["value"] == format(plan.est_monthly_fees_usd, "f") + assert plan.cap.allowance_usd is not None + assert body["summary"]["cap"]["value"] == format(plan.cap.allowance_usd, "f") + assert body["summary"]["cap"]["display"] == "$600.00" + assert body["summary"]["cap"]["state"] == "good" + assert body["fee_rate"]["display"] == "1.2% (configured fees.taker_pct)" + assert body["cap_note"] == RAIL14_NOTE + (excluded,) = body["excluded"] + (reason,) = plan.excluded[0].reasons + assert excluded["asset"] == "PAXG" and excluded["reasons"] == REASON_TEXT[reason] + assert body["warnings"] == list(plan.warnings) + assert body["blockers"] == [] + + +def test_the_dca_payload_carries_the_worst_month_cap_check(valid_config_path: Path) -> None: + """#847: the figure rail 14 is checked against is the WORST calendar month, and the card must + say so -- the same sentence the CLI prints, with the plan's own checked total as its value.""" + from keel.commands.dca_plan import worst_month_text + + fits = _dca_plan(valid_config_path, cap="600") + body = payload.dca_plan_payload(fits, command="x") + assert body["cap_check"]["display"] == worst_month_text(fits) + assert body["cap_check"]["value"] == format(fits.worst_month_spend_usd, "f") == "517.35" + assert body["cap_check"]["state"] == "good" + + over = _dca_plan(valid_config_path, cap="500") + blocked = payload.dca_plan_payload(over, command="x") + assert blocked["cap_check"]["state"] == "bad" + assert blocked["cap_check"]["display"] == worst_month_text(over) + + +def test_an_unlimited_cap_has_nothing_to_check_the_worst_month_against( + valid_config_path: Path, +) -> None: + """An unlimited tier (`free_volume_usd=None`): the sentence is still shown, the cap reads + "unlimited", and the check is judged neither good nor bad -- there is nothing to exceed.""" + from keel.commands.dca_plan import worst_month_text + + plan = _dca_plan(valid_config_path, cap="unlimited") + assert plan.cap.allowance_usd is None # the fixture reached the unlimited branch + body = payload.dca_plan_payload(plan, command="x") + assert body["cap_check"]["state"] == "neutral" + assert body["cap_check"]["display"] == worst_month_text(plan) + assert body["summary"]["cap"]["display"] == "unlimited" + + +def test_a_blocked_plan_says_so_in_state(valid_config_path: Path) -> None: + plan = _dca_plan(valid_config_path, cap="100") + body = payload.dca_plan_payload(plan, command="x") + assert body["state"]["value"] == "blocked" and body["state"]["state"] == "bad" + assert body["blockers"] == list(plan.blockers) and len(body["blockers"]) == 1 + + +def test_an_unattested_cap_is_judged_bad(valid_config_path: Path) -> None: + body = payload.dca_plan_payload(_dca_plan(valid_config_path, cap=None), command="x") + assert body["summary"]["cap"]["state"] == "bad" + assert body["state"]["value"] == "blocked" + + +def test_a_degraded_cap_says_why_as_the_cli_does(valid_config_path: Path) -> None: + """Review of #850: the CLI prints `$0.00 (because no subscription has been attested)`; the + card must carry the same reason, in the cap figure itself -- the service puts it in a blocker + only when the worst month exceeds the cap.""" + plan = _dca_plan(valid_config_path, cap=None) + assert plan.cap.degraded_reason != "" # the fixture reached the degraded branch + cap = payload.dca_plan_payload(plan, command="x")["summary"]["cap"] + assert cap["display"] == f"$0.00 (because {plan.cap.degraded_reason})" + assert plan.cap.allowance_usd is not None + assert cap["value"] == format(plan.cap.allowance_usd, "f") and cap["state"] == "bad" + + +def test_an_existing_dca_rule_is_sent_as_a_row(valid_config_path: Path) -> None: + from keel.commands.dca_plan import build_dca_plan, parse_plan_inputs + from tests.commands.test_dca_plan import NOW_TS as DCA_NOW_TS + from tests.commands.test_dca_plan import _config, _insert_dca, _repo, _screen + + repo = _repo() + rule_id = _insert_dca(repo, "ETH-USD", "candidate", budget="40") + plan = build_dca_plan( + repo, + _config(valid_config_path), + parse_plan_inputs("500", "0.1"), + venue="coinbase", + now_ts=DCA_NOW_TS, + screen_fn=_screen(), + ) + (row,) = payload.dca_plan_payload(plan, command="x")["existing"] + assert row == { + "rule_id": str(rule_id), + "product_id": "ETH-USD", + "status": "candidate", + "per_buy": payload.money(Decimal("40")), + "cadence": payload.label("7", display="every 7 days"), + "dip_bonus": "", + } + + +def test_an_existing_rules_dip_bonus_is_sent_as_the_cli_prints_it(valid_config_path: Path) -> None: + """Review of #850: the CLI marks the row `(dip_bonus_pct X)`, since such a rule's buys can + exceed its flat per-buy amount; the card's row must say so too.""" + from keel.commands.dca_plan import build_dca_plan, parse_plan_inputs + from tests.commands.test_dca_plan import NOW_TS as DCA_NOW_TS + from tests.commands.test_dca_plan import _config, _insert_dca, _repo, _screen + + repo = _repo() + _insert_dca(repo, "ETH-USD", "candidate", budget="40", dip="2.5") + plan = build_dca_plan( + repo, + _config(valid_config_path), + parse_plan_inputs("500", "0.1"), + venue="coinbase", + now_ts=DCA_NOW_TS, + screen_fn=_screen(), + ) + (rule,) = plan.existing + assert rule.dip_bonus_pct == Decimal("2.5") # the fixture's bonus reached the plan + (row,) = payload.dca_plan_payload(plan, command="x")["existing"] + assert row["dip_bonus"] == "dip_bonus_pct 2.5" + + +def test_no_dca_wire_value_is_a_json_number_and_none_says_fee_free( + valid_config_path: Path, +) -> None: + from keel.commands.dca_plan import apply_command + + for body in ( + payload.dca_plan_payload(_dca_plan(valid_config_path, rejected=("PAXG",)), command="x"), + payload.dca_plan_awaiting_payload(command=apply_command(None)), + ): + assert _json_numbers(body) == [] + assert "fee-free" not in json.dumps(body).lower() diff --git a/tests/web/test_pwa.py b/tests/web/test_pwa.py index 1f15a0fa..fcb40e75 100644 --- a/tests/web/test_pwa.py +++ b/tests/web/test_pwa.py @@ -817,3 +817,14 @@ def test_the_reconnect_field_never_navigates_to_a_pasted_origin() -> None: "the empty-token guard must come before the navigation, or a blank field reloads to a " "tokenless URL and lands straight back on the refusal" ) + + +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 + + prefix = re.search(r'const API_PREFIX = "([^"]+)"', _sw_source()) + assert prefix is not None + assert "/api/dca-plan" in web_api.API_ROUTES # the population includes the new route + outside = [path for path in web_api.API_ROUTES if not path.startswith(prefix.group(1))] + assert outside == []