feat: trailing-stop exit sweep in the backtest
Third exit model alongside target-vs-stop and the fixed take-profit. The TP sweep showed the edge lives in the fat tail (avg R keeps rising as you let winners run), but a fixed wide target is win-rate-brutal and gives everything back on a reversal. A trailing stop harvests the tail while protecting gains. Per setup the replay computes the realized R for several trail widths (3/5/7/10/ 15/20%) in a single conservative pass — stop ratchets up via max(initial_stop, peak*(1-trail)), exit on the pullback or at the horizon close, R vs the initial risk. Aggregated into a trailing sweep (win rate = share closed in profit, avg R, total R) over the qualified set and shown as a new table in the Backtest panel. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -251,6 +251,59 @@ def _tp_primitives(
|
|||||||
return risk_pct, stopped, mfe, close_pct
|
return risk_pct, stopped, mfe, close_pct
|
||||||
|
|
||||||
|
|
||||||
|
def _trailing_exits(
|
||||||
|
direction: str, entry: float, init_stop: float, trail_fracs, forward: list, horizon: int
|
||||||
|
) -> dict[int, float]:
|
||||||
|
"""Realized R per trailing-stop width, in one pass over the post-entry bars.
|
||||||
|
|
||||||
|
The stop ratchets up (never below the initial stop): ``max(init_stop,
|
||||||
|
peak*(1-trail))`` for a long. Exit when a bar pierces the current stop (filled
|
||||||
|
at the stop level), else at the horizon-end close. Each width is keyed by its
|
||||||
|
integer percent (5 for 0.05). Conservative: the stop for a bar uses the peak
|
||||||
|
through the *previous* bar (this bar's high is folded in only afterwards).
|
||||||
|
R is relative to the initial risk (entry → init_stop).
|
||||||
|
"""
|
||||||
|
long = direction == "long"
|
||||||
|
risk = abs(entry - init_stop) / entry if entry else 0.0
|
||||||
|
if risk <= 0:
|
||||||
|
return {round(f * 100): 0.0 for f in trail_fracs}
|
||||||
|
bars = forward[:horizon]
|
||||||
|
if not bars:
|
||||||
|
return {round(f * 100): 0.0 for f in trail_fracs}
|
||||||
|
|
||||||
|
result: dict[int, float] = {}
|
||||||
|
peak = entry
|
||||||
|
active = list(trail_fracs)
|
||||||
|
for r in bars:
|
||||||
|
remaining = []
|
||||||
|
for f in active:
|
||||||
|
if long:
|
||||||
|
stop_level = max(init_stop, peak * (1 - f))
|
||||||
|
if r.low <= stop_level:
|
||||||
|
result[round(f * 100)] = ((stop_level - entry) / entry) / risk
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
stop_level = min(init_stop, peak * (1 + f))
|
||||||
|
if r.high >= stop_level:
|
||||||
|
result[round(f * 100)] = ((entry - stop_level) / entry) / risk
|
||||||
|
continue
|
||||||
|
remaining.append(f)
|
||||||
|
active = remaining
|
||||||
|
if not active:
|
||||||
|
break
|
||||||
|
if long:
|
||||||
|
if r.high > peak:
|
||||||
|
peak = r.high
|
||||||
|
elif r.low < peak:
|
||||||
|
peak = r.low
|
||||||
|
|
||||||
|
last_close = bars[-1].close
|
||||||
|
timeout_r = (((last_close - entry) / entry) if long else ((entry - last_close) / entry)) / risk
|
||||||
|
for f in active:
|
||||||
|
result[round(f * 100)] = timeout_r
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _replay_ticker(symbol: str, records: list, config: dict, activation: dict) -> list[dict]:
|
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."""
|
"""Walk one ticker's history weekly, building setups and their realized outcomes."""
|
||||||
candidates: list[dict] = []
|
candidates: list[dict] = []
|
||||||
@@ -281,6 +334,9 @@ def _replay_ticker(symbol: str, records: list, config: dict, activation: dict) -
|
|||||||
risk_pct, tp_stopped, mfe_pct, tp_close_pct = _tp_primitives(
|
risk_pct, tp_stopped, mfe_pct, tp_close_pct = _tp_primitives(
|
||||||
s["direction"], s["entry"], s["stop"], forward, HORIZON
|
s["direction"], s["entry"], s["stop"], forward, HORIZON
|
||||||
)
|
)
|
||||||
|
trail_r = _trailing_exits(
|
||||||
|
s["direction"], s["entry"], s["stop"], TRAIL_LEVELS, forward, HORIZON
|
||||||
|
)
|
||||||
iso = records[i].date.isocalendar()
|
iso = records[i].date.isocalendar()
|
||||||
candidates.append({
|
candidates.append({
|
||||||
"symbol": symbol,
|
"symbol": symbol,
|
||||||
@@ -300,6 +356,7 @@ def _replay_ticker(symbol: str, records: list, config: dict, activation: dict) -
|
|||||||
"tp_stopped": tp_stopped,
|
"tp_stopped": tp_stopped,
|
||||||
"mfe_pct": mfe_pct,
|
"mfe_pct": mfe_pct,
|
||||||
"tp_close_pct": tp_close_pct,
|
"tp_close_pct": tp_close_pct,
|
||||||
|
"trail_r": trail_r,
|
||||||
})
|
})
|
||||||
return candidates
|
return candidates
|
||||||
|
|
||||||
@@ -327,6 +384,9 @@ def _bucket_stats(cands: list[dict]) -> dict:
|
|||||||
# it's a standalone fixed-% exit; exiting at the target is the target model.
|
# 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)
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
|
||||||
def _take_profit_bucket(cands: list[dict], tp: float) -> dict:
|
def _take_profit_bucket(cands: list[dict], tp: float) -> dict:
|
||||||
"""Stats for a fixed take-profit exit at +``tp`` (fraction): bank +tp if it's
|
"""Stats for a fixed take-profit exit at +``tp`` (fraction): bank +tp if it's
|
||||||
@@ -357,6 +417,27 @@ def _take_profit_bucket(cands: list[dict], tp: float) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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]
|
||||||
|
for c in cands
|
||||||
|
if c.get("trail_r", {}).get(trail_pct) is not None
|
||||||
|
]
|
||||||
|
total = len(rs)
|
||||||
|
wins = sum(1 for r in rs if r > 0)
|
||||||
|
return {
|
||||||
|
"trail_pct": trail_pct,
|
||||||
|
"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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _calibration(cands: list[dict]) -> list[dict]:
|
def _calibration(cands: list[dict]) -> list[dict]:
|
||||||
"""Predicted target probability vs realized hit rate, per probability bucket."""
|
"""Predicted target probability vs realized hit rate, per probability bucket."""
|
||||||
rows: list[dict] = []
|
rows: list[dict] = []
|
||||||
@@ -792,6 +873,7 @@ async def run_backtest(
|
|||||||
"min_momentum_percentile": current_min_pct,
|
"min_momentum_percentile": current_min_pct,
|
||||||
"sweep": sweep,
|
"sweep": sweep,
|
||||||
"take_profit_sweep": [_take_profit_bucket(qualified, tp) for tp in TP_LEVELS],
|
"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],
|
||||||
"calibration": _calibration(candidates),
|
"calibration": _calibration(candidates),
|
||||||
"signal_eval": _signal_evaluation(collected),
|
"signal_eval": _signal_evaluation(collected),
|
||||||
"signal_eval_note": (
|
"signal_eval_note": (
|
||||||
|
|||||||
@@ -89,6 +89,10 @@ export function BacktestPanel() {
|
|||||||
report?.take_profit_sweep && report.take_profit_sweep.length > 0
|
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) => r.avg_r ?? -Infinity))
|
||||||
: null;
|
: null;
|
||||||
|
const bestTrailAvgR =
|
||||||
|
report?.trailing_sweep && report.trailing_sweep.length > 0
|
||||||
|
? Math.max(...report.trailing_sweep.map((r) => r.avg_r ?? -Infinity))
|
||||||
|
: null;
|
||||||
|
|
||||||
const run = useMutation({
|
const run = useMutation({
|
||||||
mutationFn: () => triggerJob('backtest'),
|
mutationFn: () => triggerJob('backtest'),
|
||||||
@@ -286,6 +290,52 @@ export function BacktestPanel() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{report.trailing_sweep && report.trailing_sweep.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500">
|
||||||
|
Trailing-stop exit
|
||||||
|
</p>
|
||||||
|
<p className="mb-2 text-[11px] text-gray-500">
|
||||||
|
Let it run, but exit when price gives back <span className="text-gray-300">X% from its
|
||||||
|
peak</span> (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. <span className="text-gray-300">Win Rate = share closed in profit.</span> ★ = 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">Trail</th>
|
||||||
|
<th className="px-4 py-2.5 text-right">Setups</th>
|
||||||
|
<th className="px-4 py-2.5 text-right">Profitable</th>
|
||||||
|
<th className="px-4 py-2.5 text-right">Win 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.trailing_sweep.map((row) => {
|
||||||
|
const best = row.avg_r != null && row.avg_r === bestTrailAvgR;
|
||||||
|
return (
|
||||||
|
<tr key={row.trail_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.trail_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.win_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>
|
<div>
|
||||||
<p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500">
|
<p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500">
|
||||||
Probability calibration
|
Probability calibration
|
||||||
|
|||||||
@@ -239,6 +239,15 @@ export interface BacktestTakeProfitRow {
|
|||||||
total_r: number | null;
|
total_r: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BacktestTrailingRow {
|
||||||
|
trail_pct: number;
|
||||||
|
total: number;
|
||||||
|
wins: number;
|
||||||
|
win_rate: number | null;
|
||||||
|
avg_r: number | null;
|
||||||
|
total_r: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BacktestSignalEvalRow {
|
export interface BacktestSignalEvalRow {
|
||||||
signal: string;
|
signal: string;
|
||||||
weeks: number;
|
weeks: number;
|
||||||
@@ -262,6 +271,7 @@ export interface BacktestReport {
|
|||||||
min_momentum_percentile: number;
|
min_momentum_percentile: number;
|
||||||
sweep: BacktestSweepRow[];
|
sweep: BacktestSweepRow[];
|
||||||
take_profit_sweep?: BacktestTakeProfitRow[];
|
take_profit_sweep?: BacktestTakeProfitRow[];
|
||||||
|
trailing_sweep?: BacktestTrailingRow[];
|
||||||
calibration: BacktestCalibrationRow[];
|
calibration: BacktestCalibrationRow[];
|
||||||
signal_eval?: BacktestSignalEvalRow[];
|
signal_eval?: BacktestSignalEvalRow[];
|
||||||
signal_eval_note?: string;
|
signal_eval_note?: string;
|
||||||
|
|||||||
@@ -95,6 +95,43 @@ class TestTakeProfitBucket:
|
|||||||
assert b["avg_r"] is None
|
assert b["avg_r"] is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestTrailingExits:
|
||||||
|
def test_locks_gain_on_pullback(self):
|
||||||
|
# Runs to 120, then a 10% trail (from peak 120 → 108) is pierced on the drop.
|
||||||
|
res = bt._trailing_exits("long", 100.0, 90.0, (0.10,), [_bar(120, 110, 118), _bar(130, 100, 105)], 30)
|
||||||
|
assert res[10] == pytest.approx(0.8) # (108-100)/100 / 0.10 risk
|
||||||
|
|
||||||
|
def test_initial_stop_caps_loss(self):
|
||||||
|
# Trail (20%) is looser than the initial stop → initial stop governs = -1R.
|
||||||
|
res = bt._trailing_exits("long", 100.0, 90.0, (0.20,), [_bar(101, 89, 90)], 30)
|
||||||
|
assert res[20] == pytest.approx(-1.0)
|
||||||
|
|
||||||
|
def test_timeout_exits_at_close(self):
|
||||||
|
res = bt._trailing_exits("long", 100.0, 90.0, (0.20,), [_bar(105, 98, 104), _bar(106, 100, 105)], 30)
|
||||||
|
assert res[20] == pytest.approx(0.5) # close 105 → +5% / 10% risk
|
||||||
|
|
||||||
|
def test_multiple_widths_one_pass(self):
|
||||||
|
# Tighter trail locks in more here (exit at 114 vs 108).
|
||||||
|
res = bt._trailing_exits("long", 100.0, 90.0, (0.10, 0.05), [_bar(120, 110, 118), _bar(130, 100, 105)], 30)
|
||||||
|
assert res[10] == pytest.approx(0.8)
|
||||||
|
assert res[5] == pytest.approx(1.4)
|
||||||
|
|
||||||
|
|
||||||
|
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}},
|
||||||
|
]
|
||||||
|
b = bt._trailing_bucket(cands, 5)
|
||||||
|
assert b["total"] == 3
|
||||||
|
assert b["wins"] == 2
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
def test_bucket_stats_counts_and_expectancy():
|
def test_bucket_stats_counts_and_expectancy():
|
||||||
cands = [
|
cands = [
|
||||||
_cand(70, OUTCOME_TARGET_HIT, 3.0), # +3R win
|
_cand(70, OUTCOME_TARGET_HIT, 3.0), # +3R win
|
||||||
|
|||||||
Reference in New Issue
Block a user