Skip to content

console: Holdings per rule row; DCA tranches read "no stop by design" (#960, #961) - #972

Merged
eaitbrahim merged 10 commits into
mainfrom
fix/960-961-holdings-dca
Oct 12, 2026
Merged

eaitbrahim merged 10 commits into
mainfrom
fix/960-961-holdings-dca

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

Closes #960
Closes #961

What changes

#961: one bracket verdict, read by three surfaces.

  • status.bracket_verdict is now the only Paper positions render an unactionable 'NO bracket' warning — the state paper can never leave #641 judgement. These all read it:
    • keel status (_bracket_display);
    • the bracket cell on Pulse's roll-call and on Holdings (payload._bracket_field);
    • Pulse's missing-bracket count (pulse.build_pulse).
  • A non-paper tranche is NEUTRAL "no stop by design" when both of these hold:
    • its rule kind declares that it never brackets. This is Rule.places_bracket = False, a class attribute declared once, on Dca;
    • it recorded no real stop.
  • Every other non-paper tranche without a bracket stays WARN "NO bracket":
  • Pulse's "brackets missing" count no longer counts DCA tranches.
  • Paper is unchanged.
  • Zero is not a stop. Every attributed live DCA tranche records initial_stop = 0, which is the Dca.detect stop=0 sentinel. The live ledger's tranches 4–6 show this. A check of initial_stop is None alone would have left all of them warning.
    • status.recorded_stop treats a stop ≤ 0 as no stop.
    • The Holdings stop column reads the same helper, so a "$0.00" stop no longer sits next to "no stop by design".

#960: Holdings draws one table per rule row.

  • HoldingsReport.groups replaces rules. It is keyed on positions.rule_id, and each group's heading is kind · product · rule <id> (for example dca · BTC-USD · rule 6).
  • Unattributed tranches come last, as one table per kind (dca · unattributed, turtle_breakout · unattributed).
  • Group keys are built only from an integer or an ordinal (rule-6, unattributed-1). That makes the h-hold-<key> element ids unique and free of whitespace, which settles the earlier held H8.
  • Each row carries group. The client filters on row.group === group.key and prints the service's heading.

Rulings

  1. The heading uses the rule id, not cadence or amount.
    • Why: kind plus product does not tell same-product DCA rules apart (the paper ledger has rules 19 and 20, both BTC). Params can be edited and can coincide. The id is unique and is the handle keel rules takes.
    • Cost if wrong: the heading carries no budget or cadence; those are on Rules and Pulse.
  2. Unattributed tranches are split by kind.
    • Why: one "unattributed" table would put pre-v21 DCA legs and the PAXG turtle tranche together with nothing on screen to show their kind. Grouping by name used to show it.
    • Cost if wrong: up to one extra table per kind.
  3. A stop ≤ 0 counts as no stop, both for the verdict and for the Holdings stop column.
    • Why: it is the DCA sentinel, and a stop at zero protects nothing on a long.
    • Cost if wrong: a tranche whose recorded stop is really zero would read as having none. No real rule sizes against a zero stop.
  4. places_bracket is a new class attribute; accumulates is not reused.
    • Why: the sleeve-sell kinds also accumulate, and they open no tranche at all.
    • Cost if wrong: a future stop-less entry kind has to declare it. test_dca_is_the_one_kind_that_never_brackets forces that to be a deliberate change.
  5. Paper's bracket text is unchanged, including "paper stop 0" on paper DCA legs. The brief said paper behaviour stays as it is.

Verification

  • Ran ruff check keel tests packages, ruff format --check . and bare mypy: all clean. node --check passes on every static JS file and on sw.js.
  • pytest: 8286 passed, 3 skipped.
  • Mutation testing: 15 of 15 mutants were killed.
    • The mutants covered: the verdict without the kind check, the zero sentinel counted as a stop, an unknown kind excused, the judgement keyed off the stop alone, Dca without the flag, Pulse counting != BRACKETED, the heading without the id, unattributed groups first, grouping by name, the client filtering by rule_name, the client heading taken from the key, the stop cell showing the sentinel, the stop column drawn on the sentinel, the no-stop cell set to warn, and the CLI wording.
    • For each mutant, the script checked that the source had actually changed, then restored it from a saved copy byte for byte.
  • keel status was run against a scratch confirm-mode copy of the paper config and DB. The DCA lines read (no stop by design). The PAXG-style turtle tranche (with a stop) and the SOL turtle tranche (without one) read (NO bracket). In paper mode the output was the same as before.
  • Real browser: headless Chromium, with the service worker unregistered and caches cleared, against my own keel serve on port 8811 over that scratch copy.
    • Holdings drew six tables, each heading unique, each id free of whitespace: rule-21, rule-101, rule-102, rule-103, unattributed-1 (dca), unattributed-2 (turtle).
    • The DCA cells read "no stop by design" with no warn class. SOL and PAXG read <span class="warn">NO bracket</span>.
    • Pulse showed unbracketed_count 2 (warn), with 4 DCA tranches neutral.
    • There were 0 console errors and no buttons in the tranche tables.

🤖 Generated with Claude Code

eaitbrahim and others added 2 commits October 11, 2026 17:52
)

`status.bracket_verdict` is the one #641 judgement. `keel status`, Pulse's
roll-call and missing-bracket count, and the Holdings bracket cell all read it.
A non-paper tranche whose kind declares it never brackets
(`Rule.places_bracket` False -- `Dca`) and that recorded no real stop is
NEUTRAL "no stop by design". Zero is the `Dca.detect` stop sentinel, which
every attributed live DCA tranche carries, so it is not a stop. Every other
non-paper tranche without a bracket stays WARN, including the PAXG tranche
(#811). Paper is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`HoldingsReport.groups` replaces `rules` and groups by `positions.rule_id`,
not by kind. Every DCA rule is named `dca`, so the live book's DCA rules
used to share one table across products. Each group is headed by its kind,
product and rule id (`dca · BTC-USD · rule 6`): the id is the only field
that tells two same-product DCA rules apart, and it is the handle
`keel rules` takes. Unattributed tranches come last, one table per kind.
Group keys are built only from an integer or an ordinal (`rule-6`,
`unattributed-1`), so the element ids built from them (`h-hold-<key>`)
are unique and safe. Each row carries its `group`, and the client filters on
that key and prints the service's heading.

The Holdings stop column reads `status.recorded_stop`. The DCA `stop=0`
sentinel is absent there, not "$0.00", and on its own it no longer draws
the column. This is the same reading the bracket verdict takes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
eaitbrahim and others added 8 commits October 11, 2026 18:19
recorded_stop reads <= 0 as no stop. A negative stop on DCA is by design; on any other kind it
stays BRACKET_MISSING. Neither side was pinned, so flipping > 0 to != 0 survived mutation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…erdict names (F1)

Applying recorded_stop everywhere changed paper: a paper DCA tranche with stop 0 showed a dash in
the stop cell beside 'paper stop $0.00' in the bracket cell, and the stop column could vanish.
status.shown_stop(initial_stop, mode) now makes that one decision (paper: raw, as on
origin/main; otherwise recorded_stop); holdings.py and payload.py read it and neither compares
the mode. recorded_stop's docstring now says what is true. Also notes in _kind_never_brackets
that rule_kind is rule.name, which is always the kind (H2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…se no longer equates unbracketed with no stop (H5)

A page-wide guard drew an all-dash stop column in every DCA table whenever the PAXG turtle
tranche (the only one with a stop) sat elsewhere on the page. Each group now carries its own
stop_rows_present list (computed in the service from shown_stop), and holdingsSection reads it
inside the group loop. The page-wide stop_rows_present and HoldingsReport.stop_row_ids had no
other reader and are gone. The structural test pins the guard inside the loop, read from group.,
one guard serving both splices. Runbook and header comments follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ayload groups (R2-F1)

Two mutants survived round 2: dropping stop_row_ids from rule-attributed groups, and giving every payload group the first group's list. Either would strip the stop column from a real turtle table while the suite stayed green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paper DCA still shows 'paper stop $0.00' with a stop column, and a live DCA tranche that recorded a real stop reads NO bracket; the runbook and render.js prose said DCA never has a stop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ence does (R3)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…name param

The #961 safety argument (a non-DCA rule never carries rule_name dca) rested on
behaviour no test locked. Pin both halves: describe()[params] carries no name
for any registered kind, and add_rule_row refuses name in --params with no row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-F1)

The docstring said a name in --params was dropped from the stored row; it is
refused (dropped-param refusal, or rsi_meanrev's constructor). Say what the
code does and cite the pinning tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@eaitbrahim

Copy link
Copy Markdown
Contributor Author

Gatekeeper: READY for the owner to merge (head c686c35)

I ran 5 review/fix rounds with a fresh reviewer each round. Findings per round: 2, 2, 1, 1, 0. All of them are fixed, and none is open.

Changes in the review loop

  • Paper is unchanged. A paper tranche's Holdings stop cell and stop list show the stop the bracket verdict names: the raw value in paper, and recorded_stop in live. One helper decides this: status.shown_stop.
  • The stop column is decided per table. Each group carries its own stop_rows_present, so the DCA tables draw no stop column while the PAXG table does. Done-means: "stop only when one exists".
  • New pins:
    • a negative-stop verdict case;
    • an attributed turtle's stop listed under its own group;
    • per-group stop lists across groups;
    • every registry kind is named after its kind, and no name is stored in its params;
    • keel rules add refuses a name param and writes no row.
  • Runbook, docstring and comment wording now says only what is true in both live and paper.

Safety

Evidence on c686c35

  • CI: 4/4 green.
  • pytest: 8307 passed, 3 skipped. ruff, format and mypy are clean. node --check passes on every static JS file and sw.js.
  • Browser (own serve, service worker unregistered):
    • Holdings shows 4 tables, one per rule row, with the unattributed table last.
    • Only the PAXG table has a stop column.
    • The DCA tranches read "no stop by design" (neutral); SOL and PAXG read "NO bracket" (warn).
    • Headers and cells pair up, ids are unique, there are 0 buttons and 0 errors.
    • Pulse shows 2 brackets missing.
  • keel status in confirm mode agrees with the browser, and paper strings are unchanged.

Filed outside this PR: #973 (keel positions measures DCA stop distance against the 0 sentinel), #974 (scripts/rule_manifest.py apply can store a name param).
Held: doctor's position.unprotected judges brackets on its own (it agrees on DCA); rule_name is still declared sortable; grouping cost is quadratic, which is fine at today's ~10 tranches.

🤖 Generated with Claude Code

@eaitbrahim
eaitbrahim merged commit 76382ee into main Oct 12, 2026
4 checks passed
@eaitbrahim
eaitbrahim deleted the fix/960-961-holdings-dca branch October 12, 2026 15:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant