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. +
+| Min Target Prob | +Qualified | +Wins | +Losses | +Hit Rate | +Avg R | +Total 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