From 582bf7de900526a88a852bacf7fc12b937d43aad Mon Sep 17 00:00:00 2001 From: Elmehdi Aitbrahim Date: Tue, 13 Oct 2026 20:01:49 -0400 Subject: [PATCH 1/2] sim report: word an empty-side Avg win/loss (R) as its profit factor does (#1004) The edge table's `Avg win (R)` / `Avg loss (R)` printed the engine's empty-side `0` as `0.000`, which reads as a measured flat side. They now print `n/a (no wins yet)` / `n/a (no losses yet)` through `undefined_reading` and a new shared `insights.avg_r_reason_of`, whose side is decided by counting the R sample (`n_wins_r` / `n_losses_r`), not by reading a zero -- so a side whose trades truly average 0 R keeps `0.000`. A row with no R stays a bare `n/a`. `BacktestResult`, `pool_stats` and the gates are unchanged; insights stays a lazy import in the report. Co-Authored-By: Claude Opus 5.5 --- keel/commands/insights.py | 19 ++++++++++- keel/sim/report.py | 27 +++++++++++++-- tests/sim/test_report.py | 71 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+), 4 deletions(-) diff --git a/keel/commands/insights.py b/keel/commands/insights.py index c3db0646..b4e7d296 100644 --- a/keel/commands/insights.py +++ b/keel/commands/insights.py @@ -45,7 +45,7 @@ from dataclasses import asdict, dataclass from datetime import UTC, datetime from decimal import Decimal -from typing import Any +from typing import Any, Literal import click from keel_core.types import EquityReading @@ -648,6 +648,23 @@ def profit_factor_r_reason_of(stats: Any) -> str | None: ) +def avg_r_reason_of(stats: Any, outcome: Literal["win", "loss"]) -> str | None: + """Why a `BacktestResult`'s `avg_win_r` (`outcome="win"`) or `avg_loss_r` (`"loss"`) is not + a measurement, or `None` when it is (#1004) -- for the `keel sim` report's `Avg win (R)` / + `Avg loss (R)` cells. Decided by COUNTING the R sample's trades on that side (`n_wins_r` / + `n_losses_r`), never by reading the average: the engine writes 0 for an empty side (the + figure `promotion.pool_stats` multiplies by a zero count), and a side whose trades truly + average 0 R is a measured 0. So an empty side reads `no wins yet` / `no losses yet`, the + words `_empty_side` gives a track record's empty side. An empty R sample returns + `NO_TRADES_YET`, which the edge table prints as a bare `n/a` like every R figure of a row + with no R. The engine's value is untouched.""" + if stats.n_trades - stats.n_excluded_no_risk == 0: + return NO_TRADES_YET + if outcome == "win": + return None if stats.n_wins_r else NO_WINS_YET + return None if stats.n_losses_r else NO_LOSSES_YET + + @dataclass(frozen=True) class GateDistance: rule_name: str diff --git a/keel/sim/report.py b/keel/sim/report.py index e33c9432..de1d8876 100644 --- a/keel/sim/report.py +++ b/keel/sim/report.py @@ -60,7 +60,7 @@ from collections.abc import Callable from dataclasses import dataclass, replace from decimal import Decimal -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal from keel.execution import sizing from keel.execution.guards import _asset @@ -1004,8 +1004,8 @@ def _render_edge_section( label = f"**{key}**" if key == POOLED_KEY else key lines.append( f"| {label} | {result.n_trades} | {result.win_rate:.1%} | " - f"{_r_cell(result.expectancy_r)} | {_r_cell(result.avg_win_r)} | " - f"{_r_cell(result.avg_loss_r)} | {_profit_factor_r_cell(result)} | " + f"{_r_cell(result.expectancy_r)} | {_avg_r_cell(result, 'win')} | " + f"{_avg_r_cell(result, 'loss')} | {_profit_factor_r_cell(result)} | " f"{_r_cell(result.max_drawdown_r)} | {result.max_losing_streak} | " f"{_r_cell(result.avg_mfe_r)} | {_r_cell(result.avg_mae_r)} |" ) @@ -1101,6 +1101,27 @@ def _profit_factor_r_cell(result: BacktestResult) -> str: return _r_cell(result.profit_factor_r) +def _avg_r_cell(result: BacktestResult, outcome: Literal["win", "loss"]) -> str: + """The edge table's `Avg win (R)` / `Avg loss (R)` cell (#1004): a bare `n/a` when the row + has no R (as `_r_cell` gives every R figure), `undefined_reading` of the reason when the R + sample has no trade on that side -- `n/a (no wins yet)` / `n/a (no losses yet)`, through the + one shared `insights.avg_r_reason_of`, the words the `Profit factor (R)` cell beside it uses + -- and the measured mean otherwise, `0.000` included when that side's trades average 0 R. + + The engine's empty-side `0` was printed here until #1004; it read as a measured flat side. + `BacktestResult` is unchanged (`promotion.pool_stats` weights these means by their counts).""" + value = result.avg_win_r if outcome == "win" else result.avg_loss_r + if value is None: + return _r_cell(None) + # Lazy: keeps click and the commands layer out of `import keel.sim.report`. + from keel.commands.insights import avg_r_reason_of, undefined_reading + + reason = avg_r_reason_of(result, outcome) + if reason is not None: + return undefined_reading(reason) + return _r_cell(value) + + def _r_cell(value: Decimal | None) -> str: """An R aggregate for the edge table: `n/a` when the sample had no R (never a 0 that reads as a measured flat edge). `inf` is unreachable from engine output: an EMPTY diff --git a/tests/sim/test_report.py b/tests/sim/test_report.py index 72a380ba..a82a3e41 100644 --- a/tests/sim/test_report.py +++ b/tests/sim/test_report.py @@ -1393,3 +1393,74 @@ def test_importing_sim_report_does_not_load_click_or_the_commands_layer(): ) done = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, check=True) assert done.stdout.strip() == "[]" + + +_AVG_WIN_R, _AVG_LOSS_R = 4, 5 # the "Avg win (R)" / "Avg loss (R)" columns + + +def test_edge_section_words_an_empty_side_average_r_as_its_profit_factor_does(): + """#1004: `Avg win (R)` / `Avg loss (R)` of a side the R sample has no trade on read + `n/a (no wins yet)` / `n/a (no losses yet)`, not the engine's `0` -- a 0 there reads as a + measured flat side. Sides are decided by COUNTING (`n_wins_r` / `n_losses_r`), so a side + that HAS trades and averages exactly 0 R (the hand-built zero-R win and zero-R loss, which + guard counting over reading) keeps `0.000`. A row with no R at all stays a bare `n/a`.""" + all_wins = summarize([_r_trade_at(_HOUR, "20"), _r_trade_at(2 * _HOUR, "10")]) + all_scratch = summarize([_scratch_r_trade_at(_HOUR), _scratch_r_trade_at(2 * _HOUR)]) + all_losses = summarize([_r_trade_at(_HOUR, "-10")]) + zero_r_sides = summarize( + [ + dataclasses.replace(_r_trade_at(_HOUR, "0"), outcome="win"), + dataclasses.replace(_r_trade_at(2 * _HOUR, "0"), outcome="loss"), + ] + ) + both_sides = summarize([_r_trade_at(_HOUR, "20"), _r_trade_at(2 * _HOUR, "-10")]) + no_trades = summarize([]) + # The fixtures reach the cases they name: the engine's raw values and counts. + assert (all_wins.avg_loss_r, all_wins.n_losses_r) == (0, 0) + assert (all_losses.avg_win_r, all_losses.n_wins_r) == (0, 0) + assert (all_scratch.avg_win_r, all_scratch.avg_loss_r) == (0, 0) + assert (all_scratch.n_wins_r, all_scratch.n_losses_r) == (0, 0) + assert (zero_r_sides.avg_win_r, zero_r_sides.avg_loss_r) == (0, 0) + assert (zero_r_sides.n_wins_r, zero_r_sides.n_losses_r) == (1, 1) + assert no_trades.avg_win_r is None and no_trades.avg_loss_r is None + + edge = { + "wins:A": all_wins, + "scratch:B": all_scratch, + "losses:C": all_losses, + "zero:D": zero_r_sides, + "both:E": both_sides, + "none:F": no_trades, + POOLED_KEY: all_losses, + } + table = _table(_render_edge_section(edge, Decimal("0.012"))) + + assert [(row[0], row[_AVG_WIN_R], row[_AVG_LOSS_R]) for row in table[1:]] == [ + ("wins:A", "1.500", "n/a (no losses yet)"), + ("scratch:B", "n/a (no wins yet)", "n/a (no losses yet)"), + ("losses:C", "n/a (no wins yet)", "-1.000"), + ("zero:D", "0.000", "0.000"), + ("both:E", "2.000", "-1.000"), + ("none:F", "n/a", "n/a"), + (f"**{POOLED_KEY}**", "n/a (no wins yet)", "-1.000"), + ] + assert table[0][_AVG_WIN_R] == "Avg win (R)" and table[0][_AVG_LOSS_R] == "Avg loss (R)" + # Every row still has the header's eleven cells. + assert {len(row) for row in table} == {11} + # The engine's values are untouched by the rendering. + assert (all_wins.avg_loss_r, all_losses.avg_win_r) == (0, 0) + + +def test_avg_r_reason_of_an_empty_r_sample_is_no_trades_yet_on_either_side(): + """#1004: with no R sample there is no side to be empty -- the reason is `no trades yet` + (the edge table prints a bare `n/a` there, before asking). Counts of zero on both sides + must not be read as `no wins yet` / `no losses yet`: an all-unrisked sample is not one.""" + from keel.commands.insights import avg_r_reason_of + + unrisked = summarize([dataclasses.replace(_r_trade_at(_HOUR, "20"), initial_risk=None)]) + assert (unrisked.n_trades, unrisked.n_excluded_no_risk) == (1, 1) + for sample in (summarize([]), unrisked): + assert [avg_r_reason_of(sample, "win"), avg_r_reason_of(sample, "loss")] == [ + "no trades yet", + "no trades yet", + ] From 2d054665f8e4452e2cf0b66590f0458aa463b5db Mon Sep 17 00:00:00 2001 From: Elmehdi Aitbrahim Date: Tue, 13 Oct 2026 20:09:42 -0400 Subject: [PATCH 2/2] sim report: correct the _avg_r_cell docstring and test name on where the empty-side words come from (#1004) Co-Authored-By: Claude Opus 5.5 --- keel/sim/report.py | 7 +++++-- tests/sim/test_report.py | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/keel/sim/report.py b/keel/sim/report.py index de1d8876..12a5bdd9 100644 --- a/keel/sim/report.py +++ b/keel/sim/report.py @@ -1105,8 +1105,11 @@ def _avg_r_cell(result: BacktestResult, outcome: Literal["win", "loss"]) -> str: """The edge table's `Avg win (R)` / `Avg loss (R)` cell (#1004): a bare `n/a` when the row has no R (as `_r_cell` gives every R figure), `undefined_reading` of the reason when the R sample has no trade on that side -- `n/a (no wins yet)` / `n/a (no losses yet)`, through the - one shared `insights.avg_r_reason_of`, the words the `Profit factor (R)` cell beside it uses - -- and the measured mean otherwise, `0.000` included when that side's trades average 0 R. + one shared `insights.avg_r_reason_of`, the empty-side words (`insights._empty_side`'s family) + a track record gives its empty side -- and the measured mean otherwise, `0.000` included when + that side's trades average 0 R. Unlike the `Profit factor (R)` cell beside it, an all-losses + row's Avg win reads `no wins yet`: an average of no trades is unmeasured, while a profit + factor of 0 over real losses is measured. The engine's empty-side `0` was printed here until #1004; it read as a measured flat side. `BacktestResult` is unchanged (`promotion.pool_stats` weights these means by their counts).""" diff --git a/tests/sim/test_report.py b/tests/sim/test_report.py index a82a3e41..9d8c5681 100644 --- a/tests/sim/test_report.py +++ b/tests/sim/test_report.py @@ -1398,7 +1398,7 @@ def test_importing_sim_report_does_not_load_click_or_the_commands_layer(): _AVG_WIN_R, _AVG_LOSS_R = 4, 5 # the "Avg win (R)" / "Avg loss (R)" columns -def test_edge_section_words_an_empty_side_average_r_as_its_profit_factor_does(): +def test_edge_section_words_an_empty_side_average_r_by_counting_the_r_sample(): """#1004: `Avg win (R)` / `Avg loss (R)` of a side the R sample has no trade on read `n/a (no wins yet)` / `n/a (no losses yet)`, not the engine's `0` -- a 0 there reads as a measured flat side. Sides are decided by COUNTING (`n_wins_r` / `n_losses_r`), so a side