fix(research): the edge table, G2 and promotion judge R, not price units (#820) - #826
Merged
Merged
Conversation
…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
force-pushed
the
fix/edge-table-r-multiples
branch
from
September 26, 2026 20:47
63b6d39 to
049c0ed
Compare
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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), butstats.summarizenever used it. Expectancy, average win and loss, profit factor, max drawdown and MFE/MAE were all built frompnl, 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: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_statsand_pooled_floorsall worked in price units.(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.What changed
Trades carry their risk.
Trade.initial_riskandSimTrade.initial_riskhold 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 toNone, so existing constructors keep working.One R formula.
rules.base.r_multiple_of(pnl, initial_risk, qty) = pnl / (initial_risk × qty). It returnsNonewhen 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.
BacktestResultgains:expectancy_r,avg_win_r,avg_loss_r,profit_factor_rmax_drawdown_r: the drawdown of cumulative Ravg_mfe_r,avg_mae_rn_excluded_no_riskn_wins_r/n_losses_r, which letpool_statspool R exactlyA 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
Nonefor 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, andshould_demote(whose docstring says it mirrorscheck_floors) now judge expectancy and R:R in R.transition's rule that missing evidence must never block pulling a rule back from real money.min_tradesandmin_win_rateare unchanged.min_expectancyis now an R threshold; every shipped config has it at 0.0.Paper history.
paper.track_recordrecoversinitial_riskfor 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 recordinitial_riskin the payload.keel rules backtestnow printsexpectancy_randmax_drawdown_rfirst, thenexpectancy_pxandmax_drawdown_pxwith a legend "(px = price units, 1-coin notional)". Before, it printed bareexpectancy=andmax_drawdown=.Behaviour changes to know about
expectancy_r … (in R),rr_r … (in R), orno R: … refusing rather than passing.insights' gate distance still displays money expectancy and R:R, as it should. Its pass/block verdict comes fromcheck_floors, which now judges R, so the blocking reasons name R.Test fixtures that changed
BacktestResultfixtures intest_promotion._stats,test_report._pooled_resultandtest_cli's pooledfake_backtestnow 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_recordstoredr_multiple="2.5"on a trade whose own pnl and stop gave 1.0R.track_recordnow recomputes R from the journalled fill and stop, so the fixture was made self-consistent (pnl 25 on a risk of 10).tests/fixtures/baseline_backtest.jsonandtests/baseline/serialize.pyare unchanged, and the baseline test passes. The baseline has no gap-below-stop entries, so every pinnedr_multipleis 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:
0.45 != -0.45and1.28… != −….[18000, 10800, 25200] != [10800, 18000, 25200].One exception:
test_g2_refuses_a_pooled_sample_with_no_rwas 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:
test_the_shared_r_helper; also, separately, the backtest, paper (×2) and portfolio_sim gap testssummarizereads pnl instead of Rtest_r_aggregates_are_identical_across_a_btc_scale_and_an_xlm_scale_samplecheck_floorsexpectancy in price unitstest_check_floors_judges_expectancy_and_rr_in_r_realized_rrin price unitstest_check_floors_judges_expectancy_and_rr_in_rtest_trades_with_no_or_zero_risk_are_excluded_from_r_and_countedtest_edge_table_pools_in_exit_time_order_so_max_drawdown_is_chronologicaltest_a_managed_stop_still_measures_r_against_the_original_stoppool_statsdoesn't pool Rtest_pooled_pass_when_the_rule_alone_is_short_but_the_parameter_set_is_nottest_g2_refuses_a_pooled_sample_with_no_rtest_track_record_recovers_initial_risk_for_a_legacy_exit_from_its_entryFull suite:
uv run pytest -qgives 6755 passed, 3 skipped, including the 34 new tests.Lint and types:
uv run ruff check keel testspasses.uv run ruff format --check keel testsfinds all 426 files formatted. Bareuv run mypyreports no issues in 468 source files.Closes #820
🤖 Generated with Claude Code