diff --git a/app/services/backtest_service.py b/app/services/backtest_service.py index 80773da..c7a62b1 100644 --- a/app/services/backtest_service.py +++ b/app/services/backtest_service.py @@ -160,6 +160,12 @@ def _window_setups( stop_loss=stop, entry_price=entry, ) + # meets_core = clears every gate EXCEPT target probability, so the report + # can sweep the min_target_probability threshold without re-replaying. + core_config = {**activation, "min_target_probability": 0.0} + meets_core = setup_qualifies(setup_ns, core_config) + best_prob = best_target_probability(setup_ns) + min_tp = float(activation.get("min_target_probability", 0.0)) out.append({ "direction": direction, "entry": entry, @@ -168,10 +174,11 @@ def _window_setups( "rr": rr, "confidence": confidences[direction], "primary_prob": float(primary["probability"]), - "best_prob": best_target_probability(setup_ns), + "best_prob": best_prob, + "meets_core": meets_core, "action": action, "risk_level": risk_level, - "qualified": setup_qualifies(setup_ns, activation), + "qualified": meets_core and best_prob >= min_tp, }) return out @@ -208,6 +215,8 @@ def _replay_ticker(symbol: str, records: list, config: dict, activation: dict) - "rr": s["rr"], "confidence": s["confidence"], "primary_prob": s["primary_prob"], + "best_prob": s["best_prob"], + "meets_core": s["meets_core"], "qualified": s["qualified"], "outcome": outcome, "target_hit": target_hit, @@ -279,6 +288,15 @@ async def run_backtest( longs = [c for c in qualified if c["direction"] == "long"] shorts = [c for c in qualified if c["direction"] == "short"] + # Threshold sweep: re-apply the gate at several min_target_probability values + # (holding the other conditions fixed) so the trade-off between how many + # setups qualify and their expectancy is visible without re-replaying. + current_min_tp = float(activation.get("min_target_probability", 60.0)) + sweep = [] + for threshold in (60, 55, 50, 45, 40, 35, 30): + cands = [c for c in candidates if c["meets_core"] and c["best_prob"] >= threshold] + sweep.append({"min_target_probability": threshold, **_bucket_stats(cands)}) + return { "generated_at": datetime.now(timezone.utc).isoformat(), "tickers": total, @@ -292,6 +310,8 @@ async def run_backtest( "long": _bucket_stats(longs), "short": _bucket_stats(shorts), }, + "min_target_probability": current_min_tp, + "sweep": sweep, "calibration": _calibration(candidates), "note": ( "Sentiment & fundamentals held neutral (no point-in-time history). " diff --git a/frontend/src/components/signals/BacktestPanel.tsx b/frontend/src/components/signals/BacktestPanel.tsx index f192ef2..3e94e87 100644 --- a/frontend/src/components/signals/BacktestPanel.tsx +++ b/frontend/src/components/signals/BacktestPanel.tsx @@ -158,6 +158,53 @@ export function BacktestPanel() { + {report.sweep && report.sweep.length > 0 && ( +
+

+ Min target-probability sweep +

+

+ How many setups qualify — and how they perform — at each gate threshold (other + gate conditions held fixed). Lower = more trades, watch that expectancy holds. + Your current setting is highlighted; set it in Admin → Settings → Activation. +

+
+ + + + + + + + + + + + + + {report.sweep.map((row) => { + const current = Math.abs(row.min_target_probability - report.min_target_probability) < 0.5; + return ( + + + + + + + + + + ); + })} + +
Min Target ProbQualifiedWinsLossesHit RateAvg RTotal R
+ {current && } + {row.min_target_probability}% + {row.total}{row.wins}{row.losses}{fmtPct(row.hit_rate)}{fmtR(row.avg_r)}{fmtR(row.total_r)}
+
+
+ )} +

Probability calibration diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 431a405..09baa32 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -196,6 +196,10 @@ export interface BacktestCalibrationRow { realized_hit_rate: number; } +export interface BacktestSweepRow extends BacktestBucket { + min_target_probability: number; +} + export interface BacktestReport { generated_at: string; tickers: number; @@ -205,6 +209,8 @@ export interface BacktestReport { overall_qualified: BacktestBucket; overall_all: BacktestBucket; by_direction: Record; + min_target_probability: number; + sweep: BacktestSweepRow[]; calibration: BacktestCalibrationRow[]; note: string; } diff --git a/tests/unit/test_backtest_service.py b/tests/unit/test_backtest_service.py index 7770715..19845fc 100644 --- a/tests/unit/test_backtest_service.py +++ b/tests/unit/test_backtest_service.py @@ -108,10 +108,15 @@ async def test_run_backtest_smoke(session): # well-formed report assert report["tickers"] == 1 assert isinstance(report["candidates"], int) - for key in ("overall_qualified", "overall_all", "by_direction", "calibration"): + for key in ("overall_qualified", "overall_all", "by_direction", "calibration", "sweep"): assert key in report # the oscillating series should yield at least some resolved setups assert report["candidates"] >= 1 + + # sweep: lowering the threshold can only add qualifiers, never remove them + sweep = sorted(report["sweep"], key=lambda r: r["min_target_probability"], reverse=True) + counts = [r["total"] for r in sweep] + assert counts == sorted(counts) # ascending as threshold descends # every calibration row is internally consistent for row in report["calibration"]: assert 0 <= row["realized_hit_rate"] <= 100