Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion keel/commands/insights.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
30 changes: 27 additions & 3 deletions keel/sim/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)} |"
)
Expand Down Expand Up @@ -1101,6 +1101,30 @@ 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 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)."""
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
Expand Down
71 changes: 71 additions & 0 deletions tests/sim/test_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_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
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",
]
Loading