From 29b1a9a28c7d666550462b9784ead959f732a567 Mon Sep 17 00:00:00 2001 From: Dennis Thiessen Date: Thu, 2 Jul 2026 07:50:37 +0200 Subject: [PATCH] feat: net-of-cost backtest, gate ablation + time-exit sweeps, longer tails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of the strategy-measurement plan — report-only, no production trading behavior changes: - Cost haircut: every bucket/sweep now reports net_avg_r/net_total_r alongside gross (COST_PER_SIDE=0.1% of notional, converted to R via each setup's stop distance); params carry cost_per_side_pct. - Gate ablation table: re-qualifies candidates at the current momentum cutoff with one floor removed per row (confidence / R:R / NEUTRAL / momentum-only) to show which floors earn their keep. - Time-based exit sweep: hold 5/10/21/30 days with the initial ATR stop, exit at the day-N close — the classic momentum implementation, to disambiguate the wide-trailing result. - TP sweep extended to +40/+50%, trailing to 25/30% so the optima are interior instead of starred at the sweep edge. - BacktestPanel: Net Avg R columns everywhere, gate-ablation and time-exit tables, stars now mark best net avg R; stale cached reports still render (all new fields optional/guarded). Co-Authored-By: Claude Fable 5 --- app/services/backtest_service.py | 196 +++++++++++++++++- .../src/components/signals/BacktestPanel.tsx | 138 +++++++++++- frontend/src/lib/types.ts | 32 ++- frontend/tsconfig.tsbuildinfo | 2 +- tests/unit/test_backtest_service.py | 161 +++++++++++++- 5 files changed, 505 insertions(+), 24 deletions(-) diff --git a/app/services/backtest_service.py b/app/services/backtest_service.py index 97aba27..6e73d72 100644 --- a/app/services/backtest_service.py +++ b/app/services/backtest_service.py @@ -44,6 +44,7 @@ from app.services.outcome_service import ( ) from app.services.price_service import query_ohlcv from app.services.qualification import ( + HIGH_CONVICTION_ACTIONS, best_target_probability, setup_qualifies, ) @@ -304,6 +305,47 @@ def _trailing_exits( return result +def _time_exits( + direction: str, entry: float, stop: float, forward: list, horizons +) -> dict[int, float]: + """Realized R per hold-N-days exit, in one pass over the post-entry bars. + + The initial stop stays active (fill at the stop level → −1R); otherwise the + trade exits at the day-N close (the last available close when history ends + early). No target, no trailing — the classic momentum implementation: buy, + hold ~N days, re-rank. Same conservative bar logic as ``_tp_primitives``: a + bar that pierces the stop is a loss before that bar's close counts. + """ + long = direction == "long" + risk = abs(entry - stop) / entry if entry else 0.0 + if risk <= 0: + return {int(n): 0.0 for n in horizons} + bars = forward[: max(int(n) for n in horizons)] + if not bars: + return {int(n): 0.0 for n in horizons} + + stop_day: int | None = None # 1-based trading day the stop was pierced + closes: list[float] = [] + for i, r in enumerate(bars): + if (r.low <= stop) if long else (r.high >= stop): + stop_day = i + 1 + break + closes.append(r.close) + + result: dict[int, float] = {} + for h in horizons: + n = int(h) + if stop_day is not None and stop_day <= n: + result[n] = -1.0 + else: + # closes can't be empty here: an empty closes means the stop hit on + # day 1, which the branch above catches for every n >= 1. + c = closes[min(n, len(closes)) - 1] + move = (c - entry) / entry if long else (entry - c) / entry + result[n] = move / risk + return result + + def _replay_ticker(symbol: str, records: list, config: dict, activation: dict) -> list[dict]: """Walk one ticker's history weekly, building setups and their realized outcomes.""" candidates: list[dict] = [] @@ -337,6 +379,9 @@ def _replay_ticker(symbol: str, records: list, config: dict, activation: dict) - trail_r = _trailing_exits( s["direction"], s["entry"], s["stop"], TRAIL_LEVELS, forward, HORIZON ) + time_r = _time_exits( + s["direction"], s["entry"], s["stop"], forward, TIME_EXIT_DAYS + ) iso = records[i].date.isocalendar() candidates.append({ "symbol": symbol, @@ -357,6 +402,7 @@ def _replay_ticker(symbol: str, records: list, config: dict, activation: dict) - "mfe_pct": mfe_pct, "tp_close_pct": tp_close_pct, "trail_r": trail_r, + "time_r": time_r, }) return candidates @@ -367,6 +413,7 @@ def _bucket_stats(cands: list[dict]) -> dict: expired = sum(1 for c in cands if c["outcome"] not in (OUTCOME_TARGET_HIT, OUTCOME_STOP_HIT, OUTCOME_AMBIGUOUS)) decided = wins + losses rs = [c["realized_r"] for c in cands] + net_rs = [c["realized_r"] - _cost_r(c) for c in cands] return { "total": len(cands), "wins": wins, @@ -375,6 +422,8 @@ def _bucket_stats(cands: list[dict]) -> dict: "hit_rate": round(wins / decided * 100, 1) if decided else None, "avg_r": round(sum(rs) / len(rs), 3) if rs else None, "total_r": round(sum(rs), 2) if rs else None, + "net_avg_r": round(sum(net_rs) / len(net_rs), 3) if net_rs else None, + "net_total_r": round(sum(net_rs), 2) if net_rs else None, } @@ -382,10 +431,26 @@ def _bucket_stats(cands: list[dict]) -> dict: # Extended into the tail so the avg-R peak/plateau is visible (it's where letting # winners run stops paying). Note: this model ignores the setup's S/R target — # it's a standalone fixed-% exit; exiting at the target is the target model. -TP_LEVELS = (0.04, 0.06, 0.08, 0.10, 0.12, 0.15, 0.20, 0.25, 0.30) +TP_LEVELS = (0.04, 0.06, 0.08, 0.10, 0.12, 0.15, 0.20, 0.25, 0.30, 0.40, 0.50) # Trailing-stop widths (give-back from the peak) swept for the trailing exit model. -TRAIL_LEVELS = (0.03, 0.05, 0.07, 0.10, 0.15, 0.20) +TRAIL_LEVELS = (0.03, 0.05, 0.07, 0.10, 0.15, 0.20, 0.25, 0.30) + +# Hold-N-days exits (initial stop stays active, exit at the day-N close) — the +# classic cross-sectional momentum implementation: buy, hold ~a month, re-rank. +TIME_EXIT_DAYS = (5, 10, 21, 30) + +# Assumed transaction cost per side as a fraction of notional (commission + +# slippage). Aggregates report gross and net side by side; net subtracts a full +# round trip, converted into R via the setup's stop distance (the 1R unit). +COST_PER_SIDE = 0.001 + + +def _cost_r(cand: dict) -> float: + """Round-trip transaction cost in R units: two sides over the 1R stop + distance. 0 when the candidate carries no usable risk_pct.""" + risk = cand.get("risk_pct") or 0.0 + return (2.0 * COST_PER_SIDE) / risk if risk > 0 else 0.0 def _take_profit_bucket(cands: list[dict], tp: float) -> dict: @@ -394,18 +459,21 @@ def _take_profit_bucket(cands: list[dict], tp: float) -> dict: Results are in R (gain% / risk%) so they're comparable to the target model. ``hit_rate`` here = share that reached +tp before the stop (the MFE CDF).""" rs: list[float] = [] + net_rs: list[float] = [] wins = 0 for c in cands: risk = c.get("risk_pct") or 0.0 if risk <= 0: continue if c.get("mfe_pct", 0.0) >= tp: - rs.append(tp / risk) + r = tp / risk wins += 1 elif c.get("tp_stopped"): - rs.append(-1.0) + r = -1.0 else: - rs.append((c.get("tp_close_pct", 0.0)) / risk) + r = (c.get("tp_close_pct", 0.0)) / risk + rs.append(r) + net_rs.append(r - _cost_r(c)) total = len(rs) return { "tp_pct": round(tp * 100, 1), @@ -414,6 +482,8 @@ def _take_profit_bucket(cands: list[dict], tp: float) -> dict: "hit_rate": round(wins / total * 100, 1) if total else None, "avg_r": round(sum(rs) / total, 3) if total else None, "total_r": round(sum(rs), 2) if total else None, + "net_avg_r": round(sum(net_rs) / total, 3) if total else None, + "net_total_r": round(sum(net_rs), 2) if total else None, } @@ -421,12 +491,14 @@ def _trailing_bucket(cands: list[dict], trail_pct: int) -> dict: """Stats for a trailing-stop exit of width ``trail_pct`` (integer percent). Each candidate carries its realized R for this width in ``trail_r``; a "win" is simply an exit in profit (R > 0).""" - rs = [ - c["trail_r"][trail_pct] + pairs = [ + (c["trail_r"][trail_pct], _cost_r(c)) for c in cands if c.get("trail_r", {}).get(trail_pct) is not None ] - total = len(rs) + total = len(pairs) + rs = [r for r, _ in pairs] + net_rs = [r - cost for r, cost in pairs] wins = sum(1 for r in rs if r > 0) return { "trail_pct": trail_pct, @@ -435,6 +507,33 @@ def _trailing_bucket(cands: list[dict], trail_pct: int) -> dict: "win_rate": round(wins / total * 100, 1) if total else None, "avg_r": round(sum(rs) / total, 3) if total else None, "total_r": round(sum(rs), 2) if total else None, + "net_avg_r": round(sum(net_rs) / total, 3) if total else None, + "net_total_r": round(sum(net_rs), 2) if total else None, + } + + +def _time_exit_bucket(cands: list[dict], hold_days: int) -> dict: + """Stats for the hold-``hold_days`` exit: initial stop active, otherwise out + at the day-N close. Each candidate carries its realized R per hold length in + ``time_r``; a "win" is an exit in profit (R > 0).""" + pairs = [ + (c["time_r"][hold_days], _cost_r(c)) + for c in cands + if c.get("time_r", {}).get(hold_days) is not None + ] + total = len(pairs) + rs = [r for r, _ in pairs] + net_rs = [r - cost for r, cost in pairs] + wins = sum(1 for r in rs if r > 0) + return { + "hold_days": hold_days, + "total": total, + "wins": wins, + "win_rate": round(wins / total * 100, 1) if total else None, + "avg_r": round(sum(rs) / total, 3) if total else None, + "total_r": round(sum(rs), 2) if total else None, + "net_avg_r": round(sum(net_rs) / total, 3) if total else None, + "net_total_r": round(sum(net_rs), 2) if total else None, } @@ -754,6 +853,72 @@ def _momentum_qualifies(cand: dict, threshold: float) -> bool: return mp is not None and mp >= threshold +def _gate_ablation(candidates: list[dict], activation: dict, threshold: float) -> list[dict]: + """Which floors earn their keep: re-qualify the same candidates at the + current momentum cutoff with one floor removed per row (long-only + throughout, matching the live gate). + + ``all_floors`` uses the stored ``meets_core`` so it reproduces the qualified + set exactly; the ablation rows recompute the remaining floors from stored + candidate fields with the same comparisons as + ``qualification.setup_qualifies``. Optional tighteners (high-conviction / + conflict exclusion), when enabled, stay applied in every ablation row so + only the named floor varies. + """ + min_rr = float(activation.get("min_rr", 0.0)) + min_conf = float(activation.get("min_confidence", 0.0)) + exclude_neutral = bool(activation.get("exclude_neutral", False)) + require_high = bool(activation.get("require_high_conviction", False)) + exclude_conflicts = bool(activation.get("exclude_conflicts", False)) + + def momentum_ok(c: dict) -> bool: + # Mirrors the momentum part of _momentum_qualifies: long-only while the + # gate is active; threshold 0 disables it (shorts pass too). + if threshold <= 0: + return True + if c["direction"] == "short": + return False + mp = c.get("momentum_percentile") + return mp is not None and mp >= threshold + + def rr_ok(c: dict) -> bool: + return c["rr"] >= min_rr + + def conf_ok(c: dict) -> bool: + return (c["confidence"] or 0.0) >= min_conf + + def neutral_ok(c: dict) -> bool: + return not exclude_neutral or (c.get("action") or "NEUTRAL") != "NEUTRAL" + + def tighteners_ok(c: dict) -> bool: + if require_high and (c.get("action") or "") not in HIGH_CONVICTION_ACTIONS: + return False + if exclude_conflicts and (c.get("risk_level") or "") != "Low": + return False + return True + + def core_ok(c: dict) -> bool: + return bool(c["meets_core"]) + + variants: list[tuple[str, list]] = [ + ("all_floors", [core_ok]), + ("no_confidence_floor", [rr_ok, neutral_ok, tighteners_ok]), + ("no_rr_floor", [conf_ok, neutral_ok, tighteners_ok]), + ("no_neutral_exclusion", [rr_ok, conf_ok, tighteners_ok]), + ("momentum_only", []), + ] + return [ + { + "variant": name, + **_bucket_stats([ + c for c in candidates + if momentum_ok(c) and all(check(c) for check in checks) + ]), + } + for name, checks in variants + ] + + async def run_backtest( db: AsyncSession, progress_cb: Callable[[int, int, str], None] | None = None, @@ -862,7 +1027,12 @@ async def run_backtest( "tickers": total, "candidates": len(candidates), "qualified": len(qualified), - "params": {"step_days": STEP_DAYS, "horizon_days": HORIZON, "min_lookback": MIN_LOOKBACK}, + "params": { + "step_days": STEP_DAYS, + "horizon_days": HORIZON, + "min_lookback": MIN_LOOKBACK, + "cost_per_side_pct": round(COST_PER_SIDE * 100, 3), + }, "activation": activation, "overall_qualified": _bucket_stats(qualified), "overall_all": _bucket_stats(candidates), @@ -872,8 +1042,16 @@ async def run_backtest( }, "min_momentum_percentile": current_min_pct, "sweep": sweep, + "gate_ablation": _gate_ablation(candidates, activation, current_min_pct), + "gate_ablation_note": ( + "Each row re-qualifies the same candidates at the current momentum " + f"cutoff ({current_min_pct:.0f}) with one floor removed (long-only " + "while the momentum gate is active). If dropping a floor doesn't " + "hurt net expectancy, that floor isn't pulling its weight." + ), "take_profit_sweep": [_take_profit_bucket(qualified, tp) for tp in TP_LEVELS], "trailing_sweep": [_trailing_bucket(qualified, round(f * 100)) for f in TRAIL_LEVELS], + "time_exit_sweep": [_time_exit_bucket(qualified, n) for n in TIME_EXIT_DAYS], "calibration": _calibration(candidates), "signal_eval": _signal_evaluation(collected), "signal_eval_note": ( diff --git a/frontend/src/components/signals/BacktestPanel.tsx b/frontend/src/components/signals/BacktestPanel.tsx index 6a14e3a..d80d88b 100644 --- a/frontend/src/components/signals/BacktestPanel.tsx +++ b/frontend/src/components/signals/BacktestPanel.tsx @@ -32,6 +32,20 @@ const SIGNAL_LABELS: Record = { vol_6m: '6-month realized volatility', }; +const ABLATION_LABELS: Record = { + all_floors: 'All floors (current gate)', + no_confidence_floor: 'Without confidence floor', + no_rr_floor: 'Without R:R floor', + no_neutral_exclusion: 'Without NEUTRAL exclusion', + momentum_only: 'Momentum only (no floors)', +}; + +// Prefer the net-of-costs number when the report carries it; older cached +// reports (pre-cost model) fall back to gross. +function netOrGross(r: { avg_r: number | null; net_avg_r?: number | null }): number | null { + return r.net_avg_r ?? r.avg_r; +} + // An |IC| this large, with a consistent sign, is a real (if small) edge worth // building on; below it, ranking on the signal sorts essentially nothing. const IC_EDGE_THRESHOLD = 0.03; @@ -76,6 +90,7 @@ function BucketRow({ label, b }: { label: string; b: BacktestBucket }) { {b.expired} {fmtPct(b.hit_rate)} {fmtR(b.avg_r)} + {fmtR(b.net_avg_r ?? null)} ); } @@ -87,11 +102,15 @@ export function BacktestPanel() { const bestTpAvgR = report?.take_profit_sweep && report.take_profit_sweep.length > 0 - ? Math.max(...report.take_profit_sweep.map((r) => r.avg_r ?? -Infinity)) + ? Math.max(...report.take_profit_sweep.map((r) => netOrGross(r) ?? -Infinity)) : null; const bestTrailAvgR = report?.trailing_sweep && report.trailing_sweep.length > 0 - ? Math.max(...report.trailing_sweep.map((r) => r.avg_r ?? -Infinity)) + ? Math.max(...report.trailing_sweep.map((r) => netOrGross(r) ?? -Infinity)) + : null; + const bestTimeAvgR = + report?.time_exit_sweep && report.time_exit_sweep.length > 0 + ? Math.max(...report.time_exit_sweep.map((r) => netOrGross(r) ?? -Infinity)) : null; const run = useMutation({ @@ -140,6 +159,9 @@ export function BacktestPanel() {

Ran {timeAgo(report.generated_at)} · {report.tickers} tickers · {report.candidates} setups ({report.qualified} qualified) · weekly cadence, {report.params.horizon_days}-day horizon + {report.params.cost_per_side_pct != null && ( + <> · net assumes {report.params.cost_per_side_pct}%/side costs + )}

@@ -179,6 +201,7 @@ export function BacktestPanel() { Expired Hit Rate Avg R + Net Avg R @@ -214,6 +237,7 @@ export function BacktestPanel() { Losses Hit Rate Avg R + Net Avg R Total R @@ -231,6 +255,7 @@ export function BacktestPanel() { {row.losses} {fmtPct(row.hit_rate)} {fmtR(row.avg_r)} + {fmtR(row.net_avg_r ?? null)} {fmtR(row.total_r)} ); @@ -241,6 +266,51 @@ export function BacktestPanel() {
)} + {report.gate_ablation && report.gate_ablation.length > 0 && ( +
+

+ Gate ablation — which floors earn their keep +

+

+ {report.gate_ablation_note ?? + 'Each row re-qualifies the same candidates at the current momentum cutoff with one floor removed (long-only throughout).'} +

+
+ + + + + + + + + + + + + {report.gate_ablation.map((row) => ( + + + + + + + + + ))} + +
VariantSetupsHit RateAvg RNet Avg RTotal R
+ {ABLATION_LABELS[row.variant] ?? row.variant} + {row.total}{fmtPct(row.hit_rate)}{fmtR(row.avg_r)} + {fmtR(row.net_avg_r ?? null)} + {fmtR(row.total_r)}
+
+
+ )} + {report.take_profit_sweep && report.take_profit_sweep.length > 0 && (

@@ -253,7 +323,7 @@ export function BacktestPanel() { target model above. Hit Rate = how often you'd have banked +X% (how far winners actually run) — no top-ticking, it's the level you'd really set. The setup's own S/R target is not used here (exiting at that target is the model - above); this is a pure fixed-% exit. ★ = best avg R. + above); this is a pure fixed-% exit. ★ = best net avg R.

@@ -264,12 +334,13 @@ export function BacktestPanel() { + {report.take_profit_sweep.map((row) => { - const best = row.avg_r != null && row.avg_r === bestTpAvgR; + const best = netOrGross(row) != null && netOrGross(row) === bestTpAvgR; return ( - + + ); @@ -299,7 +371,7 @@ export function BacktestPanel() { Let it run, but exit when price gives back X% from its peak (the stop only ratchets up, never below the initial stop). Captures the tail without the fixed take-profit's all-or-nothing miss, and protects gains. In R vs the initial - risk. Win Rate = share closed in profit. ★ = best avg R. + risk. Win Rate = share closed in profit. ★ = best net avg R.

Hit (banked) Hit Rate Avg RNet Avg R Total R
@@ -279,7 +350,8 @@ export function BacktestPanel() { {row.total} {row.wins} {fmtPct(row.hit_rate)}{fmtR(row.avg_r)}{fmtR(row.avg_r)}{fmtR(row.net_avg_r ?? null)} {fmtR(row.total_r)}
@@ -310,12 +382,13 @@ export function BacktestPanel() { + {report.trailing_sweep.map((row) => { - const best = row.avg_r != null && row.avg_r === bestTrailAvgR; + const best = netOrGross(row) != null && netOrGross(row) === bestTrailAvgR; return ( - + + + + + ); + })} + +
Profitable Win Rate Avg RNet Avg R Total R
@@ -325,7 +398,56 @@ export function BacktestPanel() { {row.total} {row.wins} {fmtPct(row.win_rate)}{fmtR(row.avg_r)}{fmtR(row.avg_r)}{fmtR(row.net_avg_r ?? null)}{fmtR(row.total_r)}
+
+
+ )} + + {report.time_exit_sweep && report.time_exit_sweep.length > 0 && ( +
+

+ Time-based exit +

+

+ Buy at detection, keep the initial ATR stop, and exit at the{' '} + day-N close — no target, no trailing. This is the + classic cross-sectional momentum implementation (hold ~a month, re-rank).{' '} + Win Rate = share closed in profit. ★ = best net avg R. +

+
+ + + + + + + + + + + + + + {report.time_exit_sweep.map((row) => { + const best = netOrGross(row) != null && netOrGross(row) === bestTimeAvgR; + return ( + + + + + + + ); diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 9437e97..0b2a3d1 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -229,6 +229,9 @@ export interface BacktestBucket { hit_rate: number | null; avg_r: number | null; total_r: number | null; + // Net of transaction costs — optional so a stale cached report still renders. + net_avg_r?: number | null; + net_total_r?: number | null; } export interface BacktestCalibrationRow { @@ -249,6 +252,8 @@ export interface BacktestTakeProfitRow { hit_rate: number | null; avg_r: number | null; total_r: number | null; + net_avg_r?: number | null; + net_total_r?: number | null; } export interface BacktestTrailingRow { @@ -258,6 +263,23 @@ export interface BacktestTrailingRow { win_rate: number | null; avg_r: number | null; total_r: number | null; + net_avg_r?: number | null; + net_total_r?: number | null; +} + +export interface BacktestTimeExitRow { + hold_days: number; + total: number; + wins: number; + win_rate: number | null; + avg_r: number | null; + total_r: number | null; + net_avg_r?: number | null; + net_total_r?: number | null; +} + +export interface BacktestGateAblationRow extends BacktestBucket { + variant: string; } export interface BacktestSignalEvalRow { @@ -276,14 +298,22 @@ export interface BacktestReport { tickers: number; candidates: number; qualified: number; - params: { step_days: number; horizon_days: number; min_lookback: number }; + params: { + step_days: number; + horizon_days: number; + min_lookback: number; + cost_per_side_pct?: number; + }; overall_qualified: BacktestBucket; overall_all: BacktestBucket; by_direction: Record; min_momentum_percentile: number; sweep: BacktestSweepRow[]; + gate_ablation?: BacktestGateAblationRow[]; + gate_ablation_note?: string; take_profit_sweep?: BacktestTakeProfitRow[]; trailing_sweep?: BacktestTrailingRow[]; + time_exit_sweep?: BacktestTimeExitRow[]; calibration: BacktestCalibrationRow[]; signal_eval?: BacktestSignalEvalRow[]; signal_eval_note?: string; diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo index 148d09a..da3b8a7 100644 --- a/frontend/tsconfig.tsbuildinfo +++ b/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/activation.ts","./src/api/admin.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/fundamentals.ts","./src/api/health.ts","./src/api/indicators.ts","./src/api/ingestion.ts","./src/api/jobs.ts","./src/api/market.ts","./src/api/ohlcv.ts","./src/api/papertrades.ts","./src/api/performance.ts","./src/api/scores.ts","./src/api/sentiment.ts","./src/api/sr-levels.ts","./src/api/tickers.ts","./src/api/trades.ts","./src/api/watchlist.ts","./src/components/admin/activationsettings.tsx","./src/components/admin/alertsettings.tsx","./src/components/admin/datacleanup.tsx","./src/components/admin/jobcontrols.tsx","./src/components/admin/pipelinereadinesspanel.tsx","./src/components/admin/recommendationsettings.tsx","./src/components/admin/schedulesettings.tsx","./src/components/admin/sentimentprovidersettings.tsx","./src/components/admin/settingsform.tsx","./src/components/admin/tickermanagement.tsx","./src/components/admin/tickeruniversebootstrap.tsx","./src/components/admin/usertable.tsx","./src/components/auth/protectedroute.tsx","./src/components/charts/candlestickchart.tsx","./src/components/dashboard/opentradespanel.tsx","./src/components/layout/appshell.tsx","./src/components/layout/mobilenav.tsx","./src/components/layout/sidebar.tsx","./src/components/rankings/rankingstable.tsx","./src/components/rankings/weightsform.tsx","./src/components/scanner/tradetable.tsx","./src/components/signals/backtestpanel.tsx","./src/components/signals/mytradespanel.tsx","./src/components/signals/setupspanel.tsx","./src/components/signals/trackrecordpanel.tsx","./src/components/ticker/dimensionbreakdownpanel.tsx","./src/components/ticker/fundamentalspanel.tsx","./src/components/ticker/indicatorselector.tsx","./src/components/ticker/recommendationpanel.tsx","./src/components/ticker/sroverlay.tsx","./src/components/ticker/sentimentpanel.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/callout.tsx","./src/components/ui/confirmdialog.tsx","./src/components/ui/disclosure.tsx","./src/components/ui/dropdown.tsx","./src/components/ui/field.tsx","./src/components/ui/pageheader.tsx","./src/components/ui/scorecard.tsx","./src/components/ui/section.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/tabs.tsx","./src/components/ui/toast.tsx","./src/components/watchlist/addtickerform.tsx","./src/components/watchlist/watchlisttable.tsx","./src/hooks/useactivation.ts","./src/hooks/useadmin.ts","./src/hooks/useauth.ts","./src/hooks/usefetchsymboldata.ts","./src/hooks/usemarketregime.ts","./src/hooks/usepapertrades.ts","./src/hooks/useperformance.ts","./src/hooks/userisksettings.ts","./src/hooks/usescores.ts","./src/hooks/usetickerdetail.ts","./src/hooks/usetickers.ts","./src/hooks/usetrades.ts","./src/hooks/usewatchlist.ts","./src/lib/format.ts","./src/lib/ingestionstatus.ts","./src/lib/papertrade.ts","./src/lib/position.ts","./src/lib/qualification.ts","./src/lib/recommendation.ts","./src/lib/regime.ts","./src/lib/types.ts","./src/pages/adminpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/loginpage.tsx","./src/pages/marketpage.tsx","./src/pages/registerpage.tsx","./src/pages/signalspage.tsx","./src/pages/tickerdetailpage.tsx","./src/stores/authstore.ts"],"version":"5.6.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/activation.ts","./src/api/admin.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/fundamentals.ts","./src/api/health.ts","./src/api/indicators.ts","./src/api/ingestion.ts","./src/api/jobs.ts","./src/api/market.ts","./src/api/ohlcv.ts","./src/api/papertrades.ts","./src/api/performance.ts","./src/api/regime.ts","./src/api/scores.ts","./src/api/sentiment.ts","./src/api/sr-levels.ts","./src/api/tickers.ts","./src/api/trades.ts","./src/api/watchlist.ts","./src/components/admin/activationsettings.tsx","./src/components/admin/alertsettings.tsx","./src/components/admin/datacleanup.tsx","./src/components/admin/exitpolicysettings.tsx","./src/components/admin/jobcontrols.tsx","./src/components/admin/pipelinereadinesspanel.tsx","./src/components/admin/recommendationsettings.tsx","./src/components/admin/schedulesettings.tsx","./src/components/admin/sentimentprovidersettings.tsx","./src/components/admin/settingsform.tsx","./src/components/admin/tickermanagement.tsx","./src/components/admin/tickeruniversebootstrap.tsx","./src/components/admin/usertable.tsx","./src/components/auth/protectedroute.tsx","./src/components/charts/candlestickchart.tsx","./src/components/dashboard/opentradespanel.tsx","./src/components/layout/appshell.tsx","./src/components/layout/mobilenav.tsx","./src/components/layout/sidebar.tsx","./src/components/layout/tickersearch.tsx","./src/components/rankings/rankingstable.tsx","./src/components/rankings/weightsform.tsx","./src/components/regime/regimequadrant.tsx","./src/components/regime/scorehistorychart.tsx","./src/components/scanner/tradetable.tsx","./src/components/signals/backtestpanel.tsx","./src/components/signals/mytradespanel.tsx","./src/components/signals/setupspanel.tsx","./src/components/signals/trackrecordpanel.tsx","./src/components/ticker/dimensionbreakdownpanel.tsx","./src/components/ticker/fundamentalspanel.tsx","./src/components/ticker/indicatorselector.tsx","./src/components/ticker/recommendationpanel.tsx","./src/components/ticker/sroverlay.tsx","./src/components/ticker/sentimentpanel.tsx","./src/components/ticker/standingmatrix.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/callout.tsx","./src/components/ui/confirmdialog.tsx","./src/components/ui/disclosure.tsx","./src/components/ui/dropdown.tsx","./src/components/ui/field.tsx","./src/components/ui/pageheader.tsx","./src/components/ui/scorecard.tsx","./src/components/ui/section.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/tabs.tsx","./src/components/ui/toast.tsx","./src/components/watchlist/addtickerform.tsx","./src/components/watchlist/watchlisttable.tsx","./src/hooks/useactivation.ts","./src/hooks/useadmin.ts","./src/hooks/useauth.ts","./src/hooks/usefetchsymboldata.ts","./src/hooks/usemarketregime.ts","./src/hooks/usepapertrades.ts","./src/hooks/useperformance.ts","./src/hooks/userisksettings.ts","./src/hooks/usescores.ts","./src/hooks/usetickerdetail.ts","./src/hooks/usetickers.ts","./src/hooks/usetrades.ts","./src/hooks/usewatchlist.ts","./src/lib/format.ts","./src/lib/ingestionstatus.ts","./src/lib/papertrade.ts","./src/lib/position.ts","./src/lib/qualification.ts","./src/lib/recommendation.ts","./src/lib/regime.ts","./src/lib/types.ts","./src/pages/adminpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/loginpage.tsx","./src/pages/marketpage.tsx","./src/pages/regimepage.tsx","./src/pages/registerpage.tsx","./src/pages/signalspage.tsx","./src/pages/tickerdetailpage.tsx","./src/stores/authstore.ts"],"version":"5.6.3"} \ No newline at end of file diff --git a/tests/unit/test_backtest_service.py b/tests/unit/test_backtest_service.py index 35f6fdf..581970c 100644 --- a/tests/unit/test_backtest_service.py +++ b/tests/unit/test_backtest_service.py @@ -25,7 +25,14 @@ async def session(): yield s -def _cand(prob: float, outcome: str, rr: float, qualified: bool = True, direction: str = "long") -> dict: +def _cand( + prob: float, + outcome: str, + rr: float, + qualified: bool = True, + direction: str = "long", + risk_pct: float = 0.05, +) -> dict: target_hit = outcome == OUTCOME_TARGET_HIT realized = rr if target_hit else (0.0 if outcome == OUTCOME_EXPIRED else -1.0) return { @@ -36,9 +43,14 @@ def _cand(prob: float, outcome: str, rr: float, qualified: bool = True, directio "realized_r": realized, "qualified": qualified, "direction": direction, + "risk_pct": risk_pct, } +# Round-trip cost in R for the default _cand risk_pct: 2 * 0.001 / 0.05 = 0.04R. +_COST_R_005 = 2 * bt.COST_PER_SIDE / 0.05 + + def _bar(high: float, low: float, close: float) -> SimpleNamespace: return SimpleNamespace(high=high, low=low, close=close) @@ -87,6 +99,9 @@ class TestTakeProfitBucket: assert b["hit_rate"] == pytest.approx(33.3, abs=0.1) assert b["total_r"] == pytest.approx(0.8, abs=0.01) assert b["avg_r"] == pytest.approx(0.267, abs=0.01) + # net: minus a 0.04R round trip per candidate (risk_pct 0.05) + assert b["net_total_r"] == pytest.approx(0.8 - 3 * _COST_R_005, abs=0.01) + assert b["net_avg_r"] == pytest.approx((0.8 - 3 * _COST_R_005) / 3, abs=0.01) def test_zero_risk_skipped(self): cands = [{"risk_pct": 0.0, "mfe_pct": 0.2, "tp_stopped": False, "tp_close_pct": 0.1}] @@ -120,9 +135,9 @@ class TestTrailingExits: class TestTrailingBucket: def test_bucket(self): cands = [ - {"trail_r": {5: 1.4, 10: 0.8}}, - {"trail_r": {5: -1.0, 10: -1.0}}, - {"trail_r": {5: 0.5, 10: 0.5}}, + {"trail_r": {5: 1.4, 10: 0.8}, "risk_pct": 0.10}, + {"trail_r": {5: -1.0, 10: -1.0}, "risk_pct": 0.10}, + {"trail_r": {5: 0.5, 10: 0.5}, "risk_pct": 0.10}, ] b = bt._trailing_bucket(cands, 5) assert b["total"] == 3 @@ -130,6 +145,116 @@ class TestTrailingBucket: assert b["win_rate"] == pytest.approx(66.7, abs=0.1) assert b["total_r"] == pytest.approx(0.9, abs=0.01) assert b["avg_r"] == pytest.approx(0.3, abs=0.01) + # net: 0.02R round trip per candidate (risk_pct 0.10) + assert b["net_total_r"] == pytest.approx(0.9 - 3 * 0.02, abs=0.01) + assert b["net_avg_r"] == pytest.approx(0.28, abs=0.01) + + +class TestTimeExits: + def test_long_exits_at_horizon_close(self): + bars = [_bar(103, 99, 102), _bar(105, 101, 104), _bar(107, 103, 106)] + res = bt._time_exits("long", 100.0, 95.0, bars, (2, 5)) + assert res[2] == pytest.approx(0.8) # close 104 → +4% / 5% risk + assert res[5] == pytest.approx(1.2) # only 3 bars → last close 106 + + def test_stop_on_first_bar_loses_everywhere(self): + res = bt._time_exits("long", 100.0, 95.0, [_bar(101, 94, 96), _bar(105, 101, 104)], (1, 5)) + assert res[1] == pytest.approx(-1.0) + assert res[5] == pytest.approx(-1.0) + + def test_stop_after_short_horizon_only_hits_long_hold(self): + # Day-2 close banked by the 2-day hold; the stop on day 3 only hits n=5. + bars = [_bar(103, 99, 102), _bar(104, 100, 103), _bar(101, 94, 95)] + res = bt._time_exits("long", 100.0, 95.0, bars, (2, 5)) + assert res[2] == pytest.approx(0.6) # close 103 → +3% / 5% risk + assert res[5] == pytest.approx(-1.0) + + def test_short_direction(self): + res = bt._time_exits("short", 100.0, 105.0, [_bar(101, 95, 96)], (1,)) + assert res[1] == pytest.approx(0.8) # close 96 → +4% / 5% risk + + def test_zero_risk_returns_zero(self): + res = bt._time_exits("long", 100.0, 100.0, [_bar(103, 99, 102)], (5,)) + assert res[5] == 0.0 + + +class TestTimeExitBucket: + def test_bucket(self): + cands = [ + {"time_r": {5: 1.4, 21: 0.8}, "risk_pct": 0.10}, + {"time_r": {5: -1.0, 21: -1.0}, "risk_pct": 0.10}, + {"time_r": {5: 0.5, 21: 0.5}, "risk_pct": 0.10}, + ] + b = bt._time_exit_bucket(cands, 5) + assert b["hold_days"] == 5 + assert b["total"] == 3 + assert b["wins"] == 2 + assert b["win_rate"] == pytest.approx(66.7, abs=0.1) + assert b["avg_r"] == pytest.approx(0.3, abs=0.01) + assert b["net_avg_r"] == pytest.approx(0.28, abs=0.01) + + def test_missing_hold_skipped(self): + b = bt._time_exit_bucket([{"time_r": {5: 1.0}}], 21) + assert b["total"] == 0 + assert b["avg_r"] is None + + +def _acand( + rr: float = 2.0, + conf: float = 60.0, + action: str = "LONG_MODERATE", + mp: float | None = 90.0, + direction: str = "long", +) -> dict: + """Ablation candidate: meets_core mirrors the default floors (min_rr 1.2, + min_confidence 55, exclude_neutral on).""" + meets = rr >= 1.2 and conf >= 55.0 and action != "NEUTRAL" + return { + "rr": rr, + "confidence": conf, + "action": action, + "momentum_percentile": mp, + "direction": direction, + "meets_core": meets, + "risk_level": "Low", + "target_hit": True, + "outcome": OUTCOME_TARGET_HIT, + "realized_r": rr, + "risk_pct": 0.05, + } + + +class TestGateAblation: + ACTIVATION = { + "min_rr": 1.2, + "min_confidence": 55.0, + "exclude_neutral": True, + "require_high_conviction": False, + "exclude_conflicts": False, + } + + def test_variant_counts(self): + cands = [ + _acand(), # clears everything + _acand(conf=40.0), # fails confidence floor + _acand(rr=1.0), # fails R:R floor + _acand(action="NEUTRAL"), # fails NEUTRAL exclusion + _acand(mp=50.0), # fails the momentum cutoff + _acand(direction="short", mp=95.0), # short — gated out + ] + rows = {r["variant"]: r for r in bt._gate_ablation(cands, self.ACTIVATION, 80.0)} + assert rows["all_floors"]["total"] == 1 + assert rows["no_confidence_floor"]["total"] == 2 + assert rows["no_rr_floor"]["total"] == 2 + assert rows["no_neutral_exclusion"]["total"] == 2 + assert rows["momentum_only"]["total"] == 4 + assert rows["all_floors"]["net_avg_r"] is not None + + def test_threshold_zero_disables_momentum_gate(self): + # Floors only: the short and the low-momentum long both pass all_floors. + cands = [_acand(mp=50.0), _acand(direction="short", mp=None)] + rows = {r["variant"]: r for r in bt._gate_ablation(cands, self.ACTIVATION, 0.0)} + assert rows["all_floors"]["total"] == 2 def test_bucket_stats_counts_and_expectancy(): @@ -149,6 +274,9 @@ def test_bucket_stats_counts_and_expectancy(): # avg R = (3 + 2 - 1 + 0) / 4 = 1.0 assert s["avg_r"] == 1.0 assert s["total_r"] == 4.0 + # net = gross minus a 0.04R round trip per candidate (risk_pct 0.05) + assert s["net_avg_r"] == pytest.approx(1.0 - _COST_R_005, abs=0.001) + assert s["net_total_r"] == pytest.approx(4.0 - 4 * _COST_R_005, abs=0.01) def test_bucket_stats_empty(): @@ -156,6 +284,15 @@ def test_bucket_stats_empty(): assert s["total"] == 0 assert s["hit_rate"] is None assert s["avg_r"] is None + assert s["net_avg_r"] is None + + +def test_bucket_stats_no_risk_pct_means_no_cost(): + c = _cand(50, OUTCOME_TARGET_HIT, 2.0) + del c["risk_pct"] + s = bt._bucket_stats([c]) + assert s["net_avg_r"] == s["avg_r"] + assert s["net_total_r"] == s["total_r"] def test_calibration_buckets(): @@ -202,11 +339,25 @@ 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", "sweep"): + for key in ( + "overall_qualified", "overall_all", "by_direction", "calibration", "sweep", + "gate_ablation", "time_exit_sweep", + ): assert key in report # the oscillating series should yield at least some resolved setups assert report["candidates"] >= 1 + # cost assumption is reported, and every bucket carries net numbers + assert report["params"]["cost_per_side_pct"] == pytest.approx(bt.COST_PER_SIDE * 100) + assert "net_avg_r" in report["overall_all"] + + # ablation baseline reproduces the qualified set exactly + ablation = {r["variant"]: r for r in report["gate_ablation"]} + assert ablation["all_floors"]["total"] == report["overall_qualified"]["total"] + + # time-exit sweep covers the configured hold lengths + assert [r["hold_days"] for r in report["time_exit_sweep"]] == list(bt.TIME_EXIT_DAYS) + # sweep: lowering the momentum-percentile cutoff can only add qualifiers sweep = sorted(report["sweep"], key=lambda r: r["min_momentum_percentile"], reverse=True) counts = [r["total"] for r in sweep]
HoldSetupsProfitableWin RateAvg RNet Avg RTotal R
+ {best && } + {row.hold_days}d + {row.total}{row.wins}{fmtPct(row.win_rate)}{fmtR(row.avg_r)}{fmtR(row.net_avg_r ?? null)} {fmtR(row.total_r)}