diff --git a/app/services/backtest_service.py b/app/services/backtest_service.py index 13a8340..9adacbf 100644 --- a/app/services/backtest_service.py +++ b/app/services/backtest_service.py @@ -375,14 +375,6 @@ def _bucket_stats(cands: list[dict]) -> dict: holds = [c["hold_days"] for c in cands if c.get("hold_days")] avg_hold = sum(holds) / len(holds) if holds else None net_avg = sum(net_rs) / len(net_rs) if net_rs else None - # Robustness: does the edge depend on a handful of outliers? Median and - # profit factor describe the distribution; ex-top-5% is the expectancy with - # the biggest winners removed — if it stays positive, the edge isn't a - # lottery ticket. - gains = sum(r for r in net_rs if r > 0) - losses_abs = -sum(r for r in net_rs if r < 0) - trim_n = math.ceil(len(net_rs) * 0.05) if net_rs else 0 - trimmed = sorted(net_rs, reverse=True)[trim_n:] if net_rs else [] return { "total": len(cands), "wins": wins, @@ -400,7 +392,21 @@ def _bucket_stats(cands: list[dict]) -> dict: "net_r_per_day": ( round(net_avg / avg_hold, 4) if net_avg is not None and avg_hold else None ), - "median_net_r": round(statistics.median(net_rs), 3) if net_rs else None, + **_robustness_stats(net_rs), + } + + +def _robustness_stats(net_rs: list[float]) -> dict: + """Distribution-shape stats: the median (typical) trade, gross wins vs + losses, and the expectancy with the top 5% of winners removed — the direct + test of whether the edge depends on a handful of outliers.""" + if not net_rs: + return {"median_net_r": None, "profit_factor": None, "net_avg_r_ex_top5": None} + gains = sum(r for r in net_rs if r > 0) + losses_abs = -sum(r for r in net_rs if r < 0) + trimmed = sorted(net_rs, reverse=True)[math.ceil(len(net_rs) * 0.05):] + return { + "median_net_r": round(statistics.median(net_rs), 3), "profit_factor": round(gains / losses_abs, 2) if losses_abs > 0 else None, "net_avg_r_ex_top5": ( round(sum(trimmed) / len(trimmed), 3) if trimmed else None @@ -466,6 +472,7 @@ def _time_exit_bucket(cands: list[dict], hold_days: int) -> dict: "net_r_per_day": ( round(net_avg / avg_hold, 4) if net_avg is not None and avg_hold else None ), + **_robustness_stats(net_rs), } @@ -1190,15 +1197,27 @@ def _build_recommendation(report: dict) -> dict: ), }) - # Robustness: does the edge survive without the biggest winners? - trimmed = q.get("net_avg_r_ex_top5") + # 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" if trimmed is not None: if trimmed > 0: items.append({ "topic": "robustness", "text": ( f"Robustness: expectancy survives removing the top 5% of winners " - f"({trimmed:+.2f}R net/trade) — the edge is not a handful of outliers." + f"({trimmed:+.2f}R net/trade {basis}) — the edge is not a handful " + "of outliers." ), }) else: @@ -1206,13 +1225,13 @@ def _build_recommendation(report: dict) -> dict: "topic": "robustness", "text": ( f"Robustness WARNING: without the top 5% of winners the edge disappears " - f"({trimmed:+.2f}R net/trade) — outlier-dependent, treat the headline " - "expectancy with caution." + f"({trimmed:+.2f}R net/trade {basis}) — outlier-dependent, treat the " + "headline expectancy with caution." ), }) headline = None - if 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: cagr_note = ( f" (~{hold_sim['cagr_pct']:.0f}% CAGR simulated)" if hold_sim is not None and hold_sim.get("cagr_pct") is not None diff --git a/frontend/src/components/signals/BacktestPanel.tsx b/frontend/src/components/signals/BacktestPanel.tsx index a05d09e..51f16eb 100644 --- a/frontend/src/components/signals/BacktestPanel.tsx +++ b/frontend/src/components/signals/BacktestPanel.tsx @@ -414,6 +414,8 @@ export function BacktestPanel() {