Skip to content

fix(research): the edge table, G2 and promotion judge R, not price units (#820) - #826

Merged
eaitbrahim merged 1 commit into
mainfrom
fix/edge-table-r-multiples
Sep 26, 2026
Merged

eaitbrahim merged 1 commit into
mainfrom
fix/edge-table-r-multiples

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

What was wrong

The simulator's edge table said its numbers were "unit-less R-multiples". They weren't. Every trade already stored the correct R (Trade.r_multiple), but stats.summarize never used it. Expectancy, average win and loss, profit factor, max drawdown and MFE/MAE were all built from pnl, which is price units for a one-coin position. A BTC trade and an XLM trade differ by five orders of magnitude in price, so:

  • The pooled row was decided by BTC. One BTC trade that lost 1R (−2000) outweighed any number of XLM trades that won 2R (+0.004 each).
  • G2 in keel simulate, and Pool min_trades across same-parameter rules, with a cross-sectional diversity floor #338's pooled promotion, read those same numbers. check_floors, _realized_rr, pool_stats and _pooled_floors all worked in price units.
  • Two more defects sat on the same path:
    • The R denominator was signed, (entry_fill − stop) × qty. When the fill gapped below the stop, the risk went negative and a losing trade got a positive R. This happened in all three close paths: backtest, paper and portfolio_sim.
    • The pooled max drawdown followed the trades in rule order (rule A's whole history, then rule B's). That sequence never happened.

What changed

  • Trades carry their risk. Trade.initial_risk and SimTrade.initial_risk hold the per-unit |entry_fill − setup.stop|: the fill the trade actually got, against the setup's ORIGINAL stop, never a trailed or break-even stop. Both fields are last and default to None, so existing constructors keep working.

  • One R formula. rules.base.r_multiple_of(pnl, initial_risk, qty) = pnl / (initial_risk × qty). It returns None when the risk is missing or zero. All three close paths use it, so R's sign now always matches the P&L's.

  • R versions of the stats, next to the money ones. BacktestResult gains:

    • expectancy_r, avg_win_r, avg_loss_r, profit_factor_r
    • max_drawdown_r: the drawdown of cumulative R
    • avg_mfe_r, avg_mae_r
    • n_excluded_no_risk
    • n_wins_r / n_losses_r, which let pool_stats pool R exactly

    A trade with no risk is left out of the R stats and counted, never treated as 0R. A sample with no R at all gives None for every R field.

    The money fields are unchanged. insights, the web payload, tuning and walk-forward still read them.

  • Edge table. The pooled row and the per-class pools (group_trades_by_class) are sorted by exit time before they are summarised. The table shows R columns under a label that is now true, and adds an "Excluded from R" line when any trades were left out.

  • Promotion.

    • check_floors, _realized_rr, pool_stats/_pooled_floors, and should_demote (whose docstring says it mirrors check_floors) now judge expectancy and R:R in R.
    • No R at all means a refusal, not a pass. For demotion, no R demotes the rule, following transition's rule that missing evidence must never block pulling a rule back from real money.
    • min_trades and min_win_rate are unchanged. min_expectancy is now an R threshold; every shipped config has it at 0.0.
  • Paper history. paper.track_record recovers initial_risk for exits written before this change from the entry payload's fill and stop. It then recomputes their R instead of trusting a value the old signed formula stored. New exits record initial_risk in the payload.

  • keel rules backtest now prints expectancy_r and max_drawdown_r first, then expectancy_px and max_drawdown_px with a legend "(px = price units, 1-coin notional)". Before, it printed bare expectancy= and max_drawdown=.

Behaviour changes to know about

  • G2 and pooled promotion can change verdict. A multi-asset pool whose sign in price units was set by BTC is now judged by its R. It can flip either way: a pool whose BTC trades lost R while cheaper assets won R now passes, and the reverse now fails. Refusal reasons now read expectancy_r … (in R), rr_r … (in R), or no R: … refusing rather than passing.
  • A single-asset rule can also move. R:R was a ratio of average pnl and is now a ratio of average R, and the two differ whenever risk per trade varies.
  • insights' gate distance still displays money expectancy and R:R, as it should. Its pass/block verdict comes from check_floors, which now judges R, so the blocking reasons name R.

Test fixtures that changed

  • The hand-built BacktestResult fixtures in test_promotion._stats, test_report._pooled_result and test_cli's pooled fake_backtest now also fill the R fields with the same numbers. Without R, the floors correctly refuse them.
  • test_journal_report_enriches_r_multiple_from_paper_track_record stored r_multiple="2.5" on a trade whose own pnl and stop gave 1.0R. track_record now recomputes R from the journalled fill and stop, so the fixture was made self-consistent (pnl 25 on a risk of 10).
  • Baseline: tests/fixtures/baseline_backtest.json and tests/baseline/serialize.py are unchanged, and the baseline test passes. The baseline has no gap-below-stop entries, so every pinned r_multiple is the same value, and the serializer doesn't emit the new fields.

Verification

  • TDD. 34 new tests. Each was run and seen failing before its implementation. Examples of the failure reasons:

    • The backtest, paper and portfolio_sim gap tests failed on the sign: 0.45 != -0.45 and 1.28… != −….
    • The chronological-pool test failed on the order: [18000, 10800, 25200] != [10800, 18000, 25200].
    • The rest failed on the missing fields and helper.

    One exception: test_g2_refuses_a_pooled_sample_with_no_r was written after the implementation. It fails on the old code, where G2 passed on price units, and a mutant kills it (M7 below).

  • Mutation. Each mutant was applied alone, checked to have changed the source, run, and restored from a saved copy. All 10 were killed:

    Mutant Killed by
    M1 signed risk test_the_shared_r_helper; also, separately, the backtest, paper (×2) and portfolio_sim gap tests
    M2a summarize reads pnl instead of R test_r_aggregates_are_identical_across_a_btc_scale_and_an_xlm_scale_sample
    M2b check_floors expectancy in price units test_check_floors_judges_expectancy_and_rr_in_r
    M2c _realized_rr in price units test_check_floors_judges_expectancy_and_rr_in_r
    M3 no-risk trades counted as 0R (no exclusion) test_trades_with_no_or_zero_risk_are_excluded_from_r_and_counted
    M4 no sort by exit time test_edge_table_pools_in_exit_time_order_so_max_drawdown_is_chronological
    M5 risk measured from the managed stop test_a_managed_stop_still_measures_r_against_the_original_stop
    M6 pool_stats doesn't pool R test_pooled_pass_when_the_rule_alone_is_short_but_the_parameter_set_is_not
    M7 no R passes the floors test_g2_refuses_a_pooled_sample_with_no_r
    M8 paper legacy exit trusts the stored signed R test_track_record_recovers_initial_risk_for_a_legacy_exit_from_its_entry
  • Full suite: uv run pytest -q gives 6755 passed, 3 skipped, including the 34 new tests.

  • Lint and types: uv run ruff check keel tests passes. uv run ruff format --check keel tests finds all 426 files formatted. Bare uv run mypy reports no issues in 468 source files.

Closes #820

🤖 Generated with Claude Code

@eaitbrahim eaitbrahim added fix Bug fix (groups under Fixes) research Measurement, validation or KB work (Research & validation) labels Sep 26, 2026
…its (#820)

Per-trade R was stored but never aggregated: `stats.summarize` built
expectancy, avg win/loss, profit factor, max drawdown and MFE/MAE from
pnl in price units (1-coin notional), and the edge table printed them
as "unit-less R-multiples". Pools across assets were decided by the
highest-priced one, and G2 and #338's pooled promotion read the same
numbers. The R denominator was also signed, so a fill that gapped below
its stop turned a loss into a positive R.

- Trade/SimTrade carry `initial_risk` (per unit, |entry_fill - stop|
  against the ORIGINAL stop); one shared helper computes R at all three
  close paths (backtest, paper, portfolio_sim).
- BacktestResult gains R twins (expectancy_r, avg_win_r, avg_loss_r,
  profit_factor_r, max_drawdown_r, avg_mfe_r, avg_mae_r) plus the
  excluded-for-no-risk count; money fields are unchanged.
- The edge table's pooled and per-class samples are sorted by exit time;
  the table renders R and says so.
- check_floors, _realized_rr, pool_stats, _pooled_floors and
  should_demote judge R; no R at all refuses (demotes, for should_demote).
- paper.track_record recovers initial_risk for pre-#820 exits from the
  entry payload and recomputes their R.
- `rules backtest` labels its units.

Closes #820

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@eaitbrahim
eaitbrahim force-pushed the fix/edge-table-r-multiples branch from 63b6d39 to 049c0ed Compare September 26, 2026 20:47
@eaitbrahim
eaitbrahim merged commit 438e78f into main Sep 26, 2026
4 checks passed
@eaitbrahim
eaitbrahim deleted the fix/edge-table-r-multiples branch September 26, 2026 20:51
eaitbrahim added a commit that referenced this pull request Sep 26, 2026
…oled evaluation as policy (#828)

MINOR: #826 changes what promotion decides (G2 and #338's pooled path
judge in R; min_expectancy is an R threshold; a sample with no R is
refused), and #825 adds report sections. No schema change since 0.17.0.

What lands:
  #826 (#820) -- edge table, G2 and promotion in R; negative-risk flip
  fixed; pooled rows in exit-time order.
  #825 (#821) -- simulate buys DCA once per completed day (was 24x per
  cadence day), no forming-day look-ahead, DCA sleeve reported.
  #824 (#822) -- ADR 0006: daily strategies evaluated on the pooled sample.
  #827 (#823) -- experiment record: hourly turtle, 4,871 trades.
  #819 -- live per-asset ceiling config mirrored (0.50 -> 0.75).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fix Bug fix (groups under Fixes) research Measurement, validation or KB work (Research & validation)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

simulate: the Edge table's "unit-less R-multiples" are price units, so the pooled row is BTC

1 participant