Skip to content

insights: one track record per rule; empty side reads unknown (#962, #965) - #975

Merged
eaitbrahim merged 3 commits into
mainfrom
fix/962-965-track-records
Oct 12, 2026
Merged

eaitbrahim merged 3 commits into
mainfrom
fix/962-965-track-records

Conversation

@eaitbrahim

@eaitbrahim eaitbrahim commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Closes #962
Closes #965

Rule track records on the Rules view, in keel insights summary and in its --json are now one record per rule. When one side of a sample is empty, its average now reads unknown instead of $0.00.

#962: one record per rule

Can trades be attributed to a rule? Yes.

  • orders.rule_id is set on every paper entry by a real paper cycle, and each exit copies its entry's id.
  • I checked the real paper databases read-only: every paper order in ~/keel/keel.db (dca and turtle_breakout) and in keel-paperhourly.db has a rule_id.

What changed:

  • paper.track_records_by_rule(repo, kind) splits a kind's paper journal by orders.rule_id. The splits do not overlap, and between them they hold every trade of the kind (pinned). track_record(repo, kind) stays as the pool for the whole kind, because the journal's R match only knows the kind.
  • Each record and its gate are built from that rule's own trades. RuleTrackRecord gains rule_id and product_id.
  • A rule outside the scope keeps its trades. For example, a disabled rule's trades used to be pooled into the paper rule of the same kind. They no longer are.
  • Trades journalled with no rule id (hand-built signals, or older fills) get one record per kind in scope, placed after the rules. That record has status="unattributed", no id, no product and no gate. They are not dropped, and they are not handed to a rule that happens to share the kind.
  • Rules view. The records table has new id and product columns, and is still unsorted. Each gate card summary reads kind · product · rule N · verdict, and the card's id is h-gatedist-<rule id>. The position-based id that PR3 added is gone.
  • CLI. Each record line reads [turtle_breakout] rule=10 product=BTC-USD status=…. The unattributed record prints rule=n/a product=n/a and a one-line note.

#965: an empty side is unknown, not $0.00

  • Report. RuleTrackRecord.avg_win and avg_loss are None when that side has no trade. This is decided by counting the side's trades, not by reading the zero.
  • Rules view. The payload's money(None) renders as "—" with state unknown.
  • CLI. The CLI prints n/a.
  • Gate verdicts are unchanged. BacktestResult, stats.py and promotion.py are untouched: pool_stats multiplies the zero by a zero count, and _realized_rr reads avg_loss_r. A test pins that each gate's (passing, blocking_reasons) equals check_floors on the same per-rule sample, and that the sample still holds 0 for the empty side.

Rulings

  • One record per rule, not one per kind. The data attributes every real paper trade, and the ledger's "Follow-up 2" ruling chose per-rule when that holds. Cost if wrong: none, because a pool for the whole kind is still track_record.

  • Gate cards now judge each rule's own sample, so the gate figures change. The verdicts here are display only. A kind-wide pool counted every product toward one rule's n_trades, without the Pool min_trades across same-parameter rules, with a cross-sectional diversity floor #338 diversity floor. keel rules promote decides from backtests plus pool_stats and is untouched. Cost if wrong: on the page, rules sit further from the floor than they did before.

  • Unattributed trades get a record per kind, not a count and not a drop. This follows the Holdings precedent (Holdings: tranches group by rule NAME, so every DCA rule shares one "dca" table #960: unattributed tranches get one table per kind, placed last). Cost if wrong: one extra row, and only on a database that has such fills.

  • --json shape: additive, plus null values.

    • Each record gains two keys, rule_id (int, or null) and product_id (str, or null).
    • avg_win and avg_loss are null when that side is empty. Before, they were "0".
    • status can be "unattributed".
    • No key was removed or renamed, and the gate object is unchanged.

    Cost if wrong: a script that does arithmetic on avg_loss must handle null.

  • realized_rr is unknown when either side is empty (review round 1).

    • With no wins, the figure was gross win 0 over the losses, and that 0 is the zero insights: avg win / avg loss show $0.00 when that side of the sample is empty (unknown as zero) #965 calls unknown. The record and the gate card now hold None: the page shows "—", and the CLI says rr=n/a (no wins yet). With no losses it still says n/a (no losses yet).
    • The gate verdict is unchanged: check_floors reads promotion._realized_rr in R, and the engine's reason line still prints rr_r 0 < min_rr.
    • In --json, realized_rr (on the record and in gate) becomes null for an all-losses record. It used to be "0".
    • Cost if wrong: the gate card shows rr "—" next to the engine's rr_r 0 reason line.
  • A zero-trade record reads unknown in the CLI text too (review round 1). The page already showed "—" for these (PR3 F5). The CLI line now reads win_rate=n/a avg_win=n/a avg_loss=n/a rr=n/a (no trades yet) expectancy=n/a profit_factor=n/a max_dd=n/a. --json keeps its numbers at n=0, so the JSON change stays additive. Cost if wrong: a JSON reader still sees 0.0 for win_rate at n=0, next to n_trades: 0.

  • The client's record order is pinned (review round 1). A structural test checks that recordsSection hands records.map( straight to the table, with no sort, reverse or copy in between.

Tests and gates (head 534f134)

  • Review. Three review rounds with mutation probes: round 1 had 21/23 killed (1 equivalent, 1 survivor, since pinned); round 2 had 18/19 (1 equivalent); round 3 had 9/9.
  • TDD. Each test was red first: paper partition ×3, per-rule records, scope, unattributed ×3, CLI and --json identity, insights: avg win / avg loss show $0.00 when that side of the sample is empty (unknown as zero) #965 ×5 including the verdict pin, payload ×2, and view header↔cell pairs plus the gate card id and summary. Review fixes, each red first: all-losses realized rr unknown (record and gate card), the zero-trade CLI line (exact full-line equality), the client record order (structural), and realized rr counting losing trades.
  • Static checks. ruff check keel tests packages, ruff format --check . and bare mypy are all clean (508 files). node --check passes on every js/*.js and on sw.js.
  • pytest: 8329 passed, 3 skipped (head 534f134). logs/keel.log was deleted before the run (tests: suite leaves logs/keel.log in CWD; next run fails test_timeline_export header tests #964).
  • Mutation. 12 probes, each proven applied and restored byte-identical. All are killed, and M4 was killed only after a pin was added for it:
    • kind-wide stats
    • empty side back to zero
    • unattributed dropped
    • unattributed folded into a rule
    • payload id dropped, or sent as ""
    • gate id built from the kind
    • id and product cells swapped
    • gate summary missing the product
    • partition key ignored
    • CLI printing None
    • CLI dropping the rule id
  • Browser.
    • Setup. My own keel serve on port 8893, against a scratch copy of the repo's config.yaml and keel.db (eight turtle_breakout rules in scope). The copy was seeded with attributed paper trades plus one unattributed trade. Headless Chromium, with the service worker unregistered (0 registrations) and caches cleared.
    • Rules view.
      • 14 headers, each over its own cell, with 9 distinguishable records: ids 10, 11, 12, 21, 22, 23, 24, 25 and "—" (unattributed).
      • The all-wins record (rule 10) shows avg loss "—", and the all-losses record (rule 22) shows avg win "—".
      • 5 gate cards: turtle_breakout · BTC-USD · rule 10 · blocked, and likewise for 11, 12, 22 and 24.
    • Page behaviour.
      • 0 sort buttons.
      • 0 duplicate ids.
      • After opening card 11 and waiting one poll, only card 11 is open.
      • 0 console errors.
  • CLI. keel insights summary (text and --json) on the same scratch copy shows rule=… and product=… on every line, avg_loss=n/a and avg_win=n/a on the empty sides, and null in the JSON.

🤖 Generated with Claude Code

…962, #965)

#962: build_insights_report read paper.track_record by KIND, so N rules of one
kind showed N identical, unnamed records and gate cards. Paper orders carry
orders.rule_id (every paper order in the real paper DBs has one), so the
report now splits each kind's journal by rule (paper.track_records_by_rule)
and builds each record and gate from its own rule's trades. RuleTrackRecord
gains rule_id and product_id; trades journalled with no rule id get one
"unattributed" record per kind in scope, after the rules, with no gate.
The Rules view shows id and product columns and names each gate card by
kind, product and rule id (its id is now the rule id). The CLI line names
rule= and product=.

#965: RuleTrackRecord.avg_win/avg_loss are None when that side of the
sample has no trade, decided by counting the side's trades. The payload's
money(None) renders absent, the CLI prints n/a. BacktestResult and the
promotion module are unchanged, so gate verdicts are check_floors on the
same sample (pinned).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lient keeps record order (#962, #965)

- realized_rr is None with no winning trade too (the zero avg_win is unknown);
  CLI reads "rr=n/a (no wins yet)". Gate verdicts unchanged (check_floors).
- A zero-trade record prints win_rate/expectancy/profit_factor/max_dd/rr as
  n/a in the CLI text; --json keeps its numbers.
- Structural test: the records table receives records.map( unreordered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fix a stale _quantized docstring that claimed a zero-loss realized rr can be Infinity.

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 534f134, base main 76382ee). I have not merged.

Review loop. Three rounds.

Evidence on 534f134

  • CI: 4/4 green.
  • pytest: 8329 passed, 3 skipped.
  • ruff check, ruff format --check and bare mypy are clean. node --check passes on every JS file and sw.js.
  • Mutants were killed in all three rounds; the only survivors are equivalent ones.

What I checked

  • Promotion untouched. promotion.py, stats.py, agent.py and rules.py are unchanged. Each gate verdict equals check_floors on that rule's own sample.
  • Browser. My own keel serve against a scratch DB, with the service worker unregistered, at 1280px:
    • 14 headers, each over its own cell, and 9 distinguishable records;
    • rule 22 (all losses) shows avg win "—" and realized rr "—";
    • 0 sort buttons, 0 duplicate ids, no page-level horizontal scroll (the table scrolls inside its wrapper, as it did on main);
    • 0 console errors.
  • CLI. keel insights summary shows rule=/product= on every line, rr=n/a (no wins yet) / (no trades yet), and n/a for every figure at n=0. --json adds only rule_id and product_id; its new nulls are announced in the description.

Issues filed

@eaitbrahim
eaitbrahim merged commit 7d021d5 into main Oct 12, 2026
4 checks passed
@eaitbrahim
eaitbrahim deleted the fix/962-965-track-records branch October 12, 2026 20:37
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