diff --git a/app/services/backtest_service.py b/app/services/backtest_service.py index 5cc6689..14a3817 100644 --- a/app/services/backtest_service.py +++ b/app/services/backtest_service.py @@ -3944,14 +3944,11 @@ def _build_recommendation(report: dict) -> dict: }) q = report.get("overall_qualified") or {} - target_net = q.get("net_avg_r") - # The best fixed hold, kept ONLY to pick the basis for the robustness check - # below. The hold-vs-target comparison itself is deliberately not reported: - # both are legacy exits the production book replaced with the ATR trail, so - # a recommendation between them could not lead to an action. - time_rows = [r for r in report.get("time_exit_sweep") or [] if r.get("net_avg_r") is not None] - best_hold = max(time_rows, key=lambda r: r["net_avg_r"], default=None) + # Nothing here reads time_exit_sweep any more. The hold-vs-target comparison + # is not reported (both are exits the production book replaced, so choosing + # between them cannot lead to an action), and the robustness check below no + # longer picks its basis from them either. # Gate floors, judged under the hold exit (the ablation's Hold column). ablation = {r["variant"]: r for r in report.get("gate_ablation") or []} @@ -4016,19 +4013,15 @@ def _build_recommendation(report: dict) -> dict: ), }) - # Robustness: does the edge survive without the biggest winners? Judged on - # the RECOMMENDED exit — outlier dependence under an exit we'd abandon - # would be the wrong warning. - hold_recommended = ( - best_hold is not None and target_net is not None - and best_hold["net_avg_r"] > target_net + _EXIT_SWITCH_THRESHOLD - ) - if hold_recommended and best_hold.get("net_avg_r_ex_top5") is not None: - trimmed = best_hold["net_avg_r_ex_top5"] - basis = f"under the recommended {best_hold['hold_days']}d hold" - else: - trimmed = q.get("net_avg_r_ex_top5") - basis = "under the S/R target exit" + # Robustness: does the edge survive without the biggest winners? + # + # There is no ATR-trail equivalent of this number in the report — the only + # ex-top-5% figure is the gate-level target/stop grading. So it is reported + # on that basis and SAYS SO, rather than being dressed up as a verdict on the + # production book. It used to pick between "the recommended Nd hold" and "the + # S/R target exit", naming a rejected exit as recommended. + trimmed = q.get("net_avg_r_ex_top5") + basis = "gate-level grading, not the production ATR-trail book" if trimmed is not None: if trimmed > 0: items.append({ @@ -4057,6 +4050,12 @@ def _build_recommendation(report: dict) -> dict: return { "headline": headline, "items": items, + # Which monitor row every production/benchmark figure above was read + # from. The page defaults its lookback selector to this, so the tiles and + # the recommendation cannot open on different windows — they used to, + # because this preferred "all" while the UI defaulted to "3y". + "basis_lookback": (production_row or {}).get("lookback"), + "basis_lookback_label": (production_row or {}).get("lookback_label"), "note": "Derived from this report's numbers on every run — the advice flips if the data does.", } diff --git a/tests/unit/test_backtest_service.py b/tests/unit/test_backtest_service.py index cae841b..3a182df 100644 --- a/tests/unit/test_backtest_service.py +++ b/tests/unit/test_backtest_service.py @@ -1287,6 +1287,12 @@ def test_build_recommendation_reads_the_report(): "strategy": "prod", "lookback": "all", "lookback_label": "All history", "cagr_pct": 40.0, "sharpe": 1.72, "max_drawdown_pct": 17.7, "total_return_pct": 297.8, "spy_return_pct": 101.9, + }, { + # A second window with DIFFERENT numbers. Without it the "all" + # preference is untested and a lookback mix-up cannot fail. + "strategy": "prod", "lookback": "3y", "lookback_label": "3y", + "cagr_pct": 47.9, "sharpe": 1.96, "max_drawdown_pct": 17.3, + "total_return_pct": 220.9, "spy_return_pct": 71.5, }], }, } @@ -1308,12 +1314,21 @@ def test_build_recommendation_reads_the_report(): assert "keep the NEUTRAL exclusion" in gate_texts assert "80" in by_topic["cutoff"][0] assert "beats" in by_topic["benchmark"][0] - # robustness is judged under the RECOMMENDED exit (the 30d hold), not the - # target model the recommendation advises abandoning - assert any( - "not a handful of outliers" in t and "under the recommended 30d hold" in t - for t in by_topic["robustness"] - ) + + # Every production figure comes from ONE window, and the report says which, + # so the page can default its selector to the same one. + assert rec["basis_lookback"] == "all" + assert rec["basis_lookback_label"] == "All history" + assert "+40.0%" in by_topic["production"][0] + assert "47.9" not in by_topic["production"][0] # the 3y row must not leak in + assert "220.9" not in by_topic["benchmark"][0] + + # Robustness names its real basis. It used to claim "under the recommended + # 30d hold" — nothing recommends that exit; production is the ATR trail. + robustness = by_topic["robustness"][0] + assert "not a handful of outliers" in robustness + assert "gate-level grading" in robustness + assert "recommended" not in robustness def test_build_recommendation_flags_outlier_dependence():