feat: take-profit exit sweep in the backtest (alongside target-vs-stop)
Deploy / lint (push) Successful in 7s
Deploy / test (push) Successful in 59s
Deploy / deploy (push) Successful in 34s

The target-vs-stop model counts a near-miss of a far S/R target as a full loss
and ignores the partial gains you actually bank — so it measures a different
strategy than "scalp the early pop, take +8%". Add a realistic take-profit exit
model next to it (original untouched).

Per setup the replay now also records risk%, whether the stop was hit, the
favourable excursion reachable before the stop (MFE), and the horizon-close move.
From those a fixed-take-profit sweep (4/6/8/10/12/15%) is scored in R: bank +X%
if reached before the stop, else -1R, else the horizon close. Hit rate = how
often +X% was banked (the MFE CDF), so you can pick the EV-optimal TP without
top-ticking fantasy. Shown as a new table in the Backtest panel; the IC,
calibration and momentum sweep are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 16:56:32 +02:00
parent 6511a1020b
commit c63951ca02
4 changed files with 199 additions and 0 deletions
@@ -85,6 +85,11 @@ export function BacktestPanel() {
const queryClient = useQueryClient();
const toast = useToast();
const bestTpAvgR =
report?.take_profit_sweep && report.take_profit_sweep.length > 0
? Math.max(...report.take_profit_sweep.map((r) => r.avg_r ?? -Infinity))
: null;
const run = useMutation({
mutationFn: () => triggerJob('backtest'),
onSuccess: (res) => {
@@ -232,6 +237,54 @@ export function BacktestPanel() {
</div>
)}
{report.take_profit_sweep && report.take_profit_sweep.length > 0 && (
<div>
<p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500">
Take-profit exit (alternative to the target above)
</p>
<p className="mb-2 text-[11px] text-gray-500">
Models a realistic exit instead of waiting for the far S/R target: bank{' '}
<span className="text-gray-300">+X%</span> if price reaches it before the stop, else 1R on
the stop, else exit at the {report.params.horizon_days}-day close. In R, so it compares to the
target model above. <span className="text-gray-300">Hit Rate = how often you'd have banked
+X%</span> (how far winners actually run) — no top-ticking, it's the level you'd really set.
★ = best avg R.
</p>
<div className="glass overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/[0.06] text-left text-xs uppercase tracking-wider text-gray-500">
<th className="px-4 py-2.5">Take-profit</th>
<th className="px-4 py-2.5 text-right">Setups</th>
<th className="px-4 py-2.5 text-right">Hit (banked)</th>
<th className="px-4 py-2.5 text-right">Hit Rate</th>
<th className="px-4 py-2.5 text-right">Avg R</th>
<th className="px-4 py-2.5 text-right">Total R</th>
</tr>
</thead>
<tbody>
{report.take_profit_sweep.map((row) => {
const best = row.avg_r != null && row.avg_r === bestTpAvgR;
return (
<tr key={row.tp_pct} className={`border-b border-white/[0.04] ${best ? 'bg-emerald-400/[0.06]' : ''}`}>
<td className="num px-4 py-2.5 text-gray-200">
{best && <span className="mr-1 text-emerald-300">★</span>}
+{row.tp_pct}%
</td>
<td className="num px-4 py-2.5 text-right text-gray-200">{row.total}</td>
<td className="num px-4 py-2.5 text-right text-emerald-400">{row.wins}</td>
<td className="num px-4 py-2.5 text-right text-gray-200">{fmtPct(row.hit_rate)}</td>
<td className={`num px-4 py-2.5 text-right font-semibold ${rColor(row.avg_r)}`}>{fmtR(row.avg_r)}</td>
<td className={`num px-4 py-2.5 text-right ${rColor(row.total_r)}`}>{fmtR(row.total_r)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
<div>
<p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500">
Probability calibration
+10
View File
@@ -230,6 +230,15 @@ export interface BacktestSweepRow extends BacktestBucket {
min_momentum_percentile: number;
}
export interface BacktestTakeProfitRow {
tp_pct: number;
total: number;
wins: number;
hit_rate: number | null;
avg_r: number | null;
total_r: number | null;
}
export interface BacktestSignalEvalRow {
signal: string;
weeks: number;
@@ -252,6 +261,7 @@ export interface BacktestReport {
by_direction: Record<string, BacktestBucket>;
min_momentum_percentile: number;
sweep: BacktestSweepRow[];
take_profit_sweep?: BacktestTakeProfitRow[];
calibration: BacktestCalibrationRow[];
signal_eval?: BacktestSignalEvalRow[];
signal_eval_note?: string;