Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 24 additions & 3 deletions keel/commands/dca_plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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


Expand Down Expand Up @@ -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."""
Expand All @@ -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 ==",
Expand Down
53 changes: 53 additions & 0 deletions keel/web/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down Expand Up @@ -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.
Expand Down
145 changes: 145 additions & 0 deletions keel/web/payload.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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=<monthly USD>&buffer=<fraction> 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),
Comment thread
eaitbrahim marked this conversation as resolved.
# 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) -------------------------------------------------------------------------------


Expand Down
14 changes: 14 additions & 0 deletions keel/web/static/css/keel.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 26 additions & 2 deletions keel/web/static/js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"] },
Expand Down Expand Up @@ -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).
*
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading