|
20 | 20 |
|
21 | 21 | from __future__ import annotations |
22 | 22 |
|
| 23 | +import re |
23 | 24 | from decimal import Decimal |
24 | 25 | from typing import Any |
25 | 26 |
|
26 | 27 | import pytest |
27 | 28 |
|
28 | 29 | from keel import agent |
| 30 | +from keel.commands import insights as insights_mod |
29 | 31 | from keel.commands import rules as rules_mod |
| 32 | +from keel.commands.insights import build_rule_track_record |
30 | 33 | from keel.commands.rules import ( |
31 | 34 | RulesRefused, |
32 | 35 | RulesUsageError, |
|
52 | 55 | from keel.strategy import backtest as backtest_mod |
53 | 56 | from keel.strategy import promotion as promotion_mod |
54 | 57 | from keel.strategy.reduction import Reduction |
| 58 | +from keel.strategy.rules.base import Trade |
55 | 59 | from keel.strategy.rules.reverse_dca import ReverseDca |
56 | | -from keel.types import Candle, Granularity |
| 60 | +from keel.strategy.stats import summarize |
| 61 | +from keel.types import Candle, Granularity, Side |
57 | 62 | from tests.data.test_sell_proposals import _row as _proposal_row |
58 | 63 | from tests.strategy.rule_conformance import minimal_params |
59 | 64 |
|
@@ -624,12 +629,155 @@ def test_run_rule_backtest_line_labels_the_units_of_every_figure(repo: Repositor |
624 | 629 | "expectancy_px", |
625 | 630 | "max_drawdown_px", |
626 | 631 | ] |
627 | | - assert f"expectancy_px={stats.expectancy} " in out[0] |
628 | | - assert f"max_drawdown_px={stats.max_drawdown} (px = price units, 1-coin notional)" in out[0] |
| 632 | + # This flat series closes no trade, so nothing was measured: the px figures read `n/a`, not |
| 633 | + # the engine's 0 (#988). The legend stays either way. |
| 634 | + assert stats.n_trades == 0 |
| 635 | + assert "expectancy_px=n/a " in out[0] |
| 636 | + assert "max_drawdown_px=n/a (px = price units, 1-coin notional)" in out[0] |
629 | 637 | assert stats.expectancy_r is None # this flat series closes no trade that carries R |
630 | 638 | assert "expectancy_r=n/a " in out[0] |
631 | 639 |
|
632 | 640 |
|
| 641 | +def _closed(outcome: str, pnl: str, i: int = 0) -> Trade: |
| 642 | + return Trade( |
| 643 | + entry_ts=1_700_000_000 + i, |
| 644 | + exit_ts=1_700_000_100 + i, |
| 645 | + entry=Decimal("100"), |
| 646 | + exit=Decimal("100") + Decimal(pnl), |
| 647 | + qty=Decimal("1"), |
| 648 | + side=Side.BUY, |
| 649 | + pnl=Decimal(pnl), |
| 650 | + r_multiple=None, |
| 651 | + mfe=Decimal(0), |
| 652 | + mae=Decimal(0), |
| 653 | + outcome=outcome, # type: ignore[arg-type] |
| 654 | + ) |
| 655 | + |
| 656 | + |
| 657 | +def _backtest_line_over( |
| 658 | + repo: Repository, monkeypatch: pytest.MonkeyPatch, trades: list[Trade] |
| 659 | +) -> tuple[str, backtest_mod.BacktestResult]: |
| 660 | + """The `rules backtest` line for a backtest that closed exactly `trades`, through the real |
| 661 | + `run_rule_backtest` (resolution, rendering) with only the engine run replaced.""" |
| 662 | + repo.insert_rule( |
| 663 | + "dca", {"product_id": "BTC-USD", "cadence_days": 7}, status="candidate", now_ts=NOW_TS |
| 664 | + ) |
| 665 | + repo.upsert_candles("BTC-USD", Granularity.ONE_DAY, _daily_candles(30)) |
| 666 | + calls: list[int] = [] |
| 667 | + |
| 668 | + def _stats(_resolved: Any) -> backtest_mod.BacktestResult: |
| 669 | + calls.append(1) |
| 670 | + return summarize(trades) |
| 671 | + |
| 672 | + monkeypatch.setattr(rules_mod, "backtest_resolved", _stats) |
| 673 | + out, err = _collect() |
| 674 | + _outcome, stats = run_rule_backtest( |
| 675 | + repo, _config(), 1, granularity_opt="ONE_DAY", echo=out.append, echo_err=err.append |
| 676 | + ) |
| 677 | + assert calls == [1] and len(out) == 1 # the replaced engine run is the one rendered |
| 678 | + return out[0], stats |
| 679 | + |
| 680 | + |
| 681 | +def _profit_factor_text(line: str) -> str: |
| 682 | + match = re.search(r" profit_factor=(.*?) expectancy_px=", line) |
| 683 | + assert match is not None, line |
| 684 | + return match.group(1) |
| 685 | + |
| 686 | + |
| 687 | +@pytest.mark.parametrize( |
| 688 | + ("trades", "engine_value", "shown"), |
| 689 | + [ |
| 690 | + # All wins: gross profit over no loss -- the engine's Infinity, a known fact (#988). |
| 691 | + ([("win", "10"), ("win", "5")], Decimal("Infinity"), "n/a (no losses yet)"), |
| 692 | + # All scratches: 0/0 -- the engine's 0 is not a measurement. |
| 693 | + ([("scratch", "0"), ("scratch", "0")], Decimal(0), "n/a (no wins or losses yet)"), |
| 694 | + # No closed trade at all: nothing was measured. |
| 695 | + ([], Decimal(0), "n/a"), |
| 696 | + # A win that made nothing: counting says it has no losses, so "no losses yet" -- reading |
| 697 | + # the zero average would say "no wins or losses". Gross 0 over gross 0 is the engine's 0. |
| 698 | + ([("win", "0")], Decimal(0), "n/a (no losses yet)"), |
| 699 | + # All losses: no gross profit over a real loss IS a measured 0, and keeps its number. |
| 700 | + ([("loss", "-10"), ("loss", "-5")], Decimal(0), "0"), |
| 701 | + # Both sides hold a trade: the ratio, as before. |
| 702 | + ([("win", "30"), ("loss", "-10")], Decimal(3), "3"), |
| 703 | + ], |
| 704 | + ids=["all-wins", "all-scratch", "no-trades", "zero-pnl-win", "all-losses", "mixed"], |
| 705 | +) |
| 706 | +def test_run_rule_backtest_words_an_empty_side_profit_factor_like_insights( |
| 707 | + repo: Repository, |
| 708 | + monkeypatch: pytest.MonkeyPatch, |
| 709 | + trades: list[tuple[str, str]], |
| 710 | + engine_value: Decimal, |
| 711 | + shown: str, |
| 712 | +) -> None: |
| 713 | + """#988: the backtest line printed the engine's `profit_factor` raw -- `Infinity` for all |
| 714 | + wins, a measured-looking `0` for all scratches or no trade. It now prints the reading |
| 715 | + `keel insights summary` prints for the same sample (#986), while the returned |
| 716 | + `BacktestResult` keeps the engine's own value.""" |
| 717 | + line, stats = _backtest_line_over( |
| 718 | + repo, monkeypatch, [_closed(o, pnl, i) for i, (o, pnl) in enumerate(trades)] |
| 719 | + ) |
| 720 | + |
| 721 | + assert stats.profit_factor == engine_value # the engine's value is untouched |
| 722 | + assert _profit_factor_text(line) == shown |
| 723 | + assert "Infinity" not in line |
| 724 | + |
| 725 | + |
| 726 | +_N0_LINE = re.compile( |
| 727 | + r"^rule 1 \(dca\): n_trades=(?P<n_trades>\S+) win_rate=(?P<win_rate>\S+) " |
| 728 | + r"expectancy_r=(?P<expectancy_r>\S+) max_drawdown_r=(?P<max_drawdown_r>\S+) " |
| 729 | + r"profit_factor=(?P<profit_factor>.+?) expectancy_px=(?P<expectancy_px>\S+) " |
| 730 | + r"max_drawdown_px=(?P<max_drawdown_px>\S+) \(px = price units, 1-coin notional\) " |
| 731 | +) |
| 732 | + |
| 733 | + |
| 734 | +def test_run_rule_backtest_prints_n_a_for_every_figure_when_no_trade_closed( |
| 735 | + repo: Repository, monkeypatch: pytest.MonkeyPatch |
| 736 | +) -> None: |
| 737 | + """#988: at n_trades == 0 `keel insights summary` prints bare `n/a` for every figure, |
| 738 | + because nothing was measured -- the engine's 0.00% / 0 are not measurements. The backtest |
| 739 | + line says the same, in the same key order, with the n and the legend untouched.""" |
| 740 | + line, stats = _backtest_line_over(repo, monkeypatch, []) |
| 741 | + |
| 742 | + match = _N0_LINE.match(line) |
| 743 | + assert match is not None, line |
| 744 | + assert match.groupdict() == { |
| 745 | + "n_trades": "0", |
| 746 | + "win_rate": "n/a", |
| 747 | + "expectancy_r": "n/a", |
| 748 | + "max_drawdown_r": "n/a", |
| 749 | + "profit_factor": "n/a", |
| 750 | + "expectancy_px": "n/a", |
| 751 | + "max_drawdown_px": "n/a", |
| 752 | + } |
| 753 | + assert stats.n_trades == 0 and stats.win_rate == 0 # display only: the engine value stays |
| 754 | + |
| 755 | + |
| 756 | +@pytest.mark.parametrize( |
| 757 | + "trades", |
| 758 | + [ |
| 759 | + [_closed("win", "10")], |
| 760 | + [_closed("scratch", "0")], |
| 761 | + [_closed("win", "0")], |
| 762 | + ], |
| 763 | + ids=["all-wins", "all-scratch", "zero-pnl-win"], |
| 764 | +) |
| 765 | +def test_run_rule_backtest_reads_the_same_reason_insights_records_for_the_sample( |
| 766 | + repo: Repository, monkeypatch: pytest.MonkeyPatch, trades: list[Trade] |
| 767 | +) -> None: |
| 768 | + """One rule, not two: for each empty-side shape the backtest LINE's profit factor IS |
| 769 | + `undefined_reading` over the reason a `RuleTrackRecord` of the same stats carries.""" |
| 770 | + line, stats = _backtest_line_over(repo, monkeypatch, trades) |
| 771 | + record = build_rule_track_record( |
| 772 | + {"id": 1, "kind": "dca", "status": "candidate", "params": {"product_id": "BTC-USD"}}, |
| 773 | + stats, |
| 774 | + promotion_mod.PromotionConfig(), |
| 775 | + ) |
| 776 | + |
| 777 | + assert record.profit_factor_reason is not None |
| 778 | + assert _profit_factor_text(line) == insights_mod.undefined_reading(record.profit_factor_reason) |
| 779 | + |
| 780 | + |
633 | 781 | # -- the sleeve_sell route through attempt_promotion (P12 Task 12.2, spec §3.7, plan R21) --------- |
634 | 782 |
|
635 | 783 | _DAY = 86_400 |
|
0 commit comments