research: --fixed-rr-tp backtest flag + 2026-08-05 verdict (NOT shipped)
Hard TP at N×risk beats the trail on all 2024+ windows but the grid is monotone to 3R (trend artifact) and M15 rr20 goes negative in 2021+2023 with doubled DD — trailing exits stay. Flag is research-only; its Module 22 tests land with the next commit (shared test_all.py). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017mbXs9HRawZhD1uZ3ze7zQ
This commit is contained in:
@@ -1441,6 +1441,12 @@ class BacktestConfig:
|
||||
# Round-trip transaction cost in price units, charged on every trade
|
||||
# (XAU/USD retail spread ≈ $0.30). 0 disables.
|
||||
spread: float = 0.30
|
||||
# RESEARCH (2026-08): >0 replaces the exit model with a hard TP at
|
||||
# this multiple of risk from entry (2.0 = fixed 1:2 RR); trailing is
|
||||
# disabled for ALL trades, including per-trade XAU_PB overrides.
|
||||
# Entries/stops/gates are untouched, so the signal book is identical
|
||||
# to the baseline — only the exit policy changes. Never shipped.
|
||||
fixed_rr_tp: float = 0.0
|
||||
# Replay worker processes for the bar loop. 0 = auto (cpu_count - 2,
|
||||
# capped at 12), 1 = sequential in-process. Signal evaluation per bar
|
||||
# is pure (past data only), so it fans out across forked workers; the
|
||||
@@ -1703,6 +1709,25 @@ def _apply_cooldown(
|
||||
return accepted
|
||||
|
||||
|
||||
def _apply_fixed_rr_tp(trades: List[Trade], mult: float) -> None:
|
||||
"""RESEARCH: replace each trade's TP with a hard target at
|
||||
`mult` × |entry − sl| and zero the per-trade trail params so the
|
||||
XAU_PB overrides cannot resurrect trailing. Risk is measured off the
|
||||
final (re-anchored) entry/SL, so call this after
|
||||
`_reanchor_entries_to_raw`. The caller must also pass
|
||||
trail_arm_r=0 to `simulate_trades` — dispatch is on the run-level arg."""
|
||||
for t in trades:
|
||||
risk = abs(t.entry_price - t.sl)
|
||||
t.tp1 = (t.entry_price + mult * risk if t.direction == "BUY"
|
||||
else t.entry_price - mult * risk)
|
||||
t.tp2 = t.tp1 # static branch never reads tp2; CSV hygiene
|
||||
t.trail_arm_r = 0.0
|
||||
t.trail_dist_r = 0.0
|
||||
t.rr_planned = mult
|
||||
if t.pip_value:
|
||||
t.reward_pips = mult * risk / t.pip_value
|
||||
|
||||
|
||||
def run_backtest(
|
||||
bt_cfg: BacktestConfig,
|
||||
platform_cfg: Optional[PlatformConfig] = None,
|
||||
@@ -1853,10 +1878,17 @@ def run_backtest(
|
||||
use_cache=bt_cfg.use_cache, clean=False,
|
||||
)
|
||||
_reanchor_entries_to_raw(trades, signal_ts_list, sim_df)
|
||||
if bt_cfg.fixed_rr_tp > 0:
|
||||
_apply_fixed_rr_tp(trades, bt_cfg.fixed_rr_tp)
|
||||
logger.info(
|
||||
f"RESEARCH override: fixed-RR TP = {bt_cfg.fixed_rr_tp} x risk; "
|
||||
f"trailing exit DISABLED for all {len(trades)} trades (incl. XAU_PB)"
|
||||
)
|
||||
run_arm = 0.0 if bt_cfg.fixed_rr_tp > 0 else platform_cfg.signals.trail_arm_r
|
||||
t_sim = time.perf_counter()
|
||||
simulate_trades(trades, sim_df, max_bars=bt_cfg.max_hold_bars,
|
||||
spread=bt_cfg.spread,
|
||||
trail_arm_r=platform_cfg.signals.trail_arm_r,
|
||||
trail_arm_r=run_arm,
|
||||
trail_dist_r=platform_cfg.signals.trail_dist_r)
|
||||
logger.info(
|
||||
f"Trade simulation took {time.perf_counter() - t_sim:.2f}s "
|
||||
@@ -2200,6 +2232,10 @@ def _parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
|
||||
help="Override signals.trail_arm_r for this run (research knob)")
|
||||
p.add_argument("--trail-dist", type=float, default=None,
|
||||
help="Override signals.trail_dist_r for this run (research knob)")
|
||||
p.add_argument("--fixed-rr-tp", type=float, default=None,
|
||||
help="RESEARCH: exit at a hard TP of MULT x risk (e.g. 2.0 = "
|
||||
"fixed 1:2 RR) instead of the trailing stop; "
|
||||
"entries/stops/gates unchanged.")
|
||||
p.add_argument("--quality", default="A+,A,B,C", help="Comma-separated allowed grades")
|
||||
p.add_argument("--strategies", default="",
|
||||
help="Comma-separated strategy tags to include (XAU,XAU_PB,XAU_CP,GENERIC). Empty = all.")
|
||||
@@ -2323,6 +2359,16 @@ def main(argv: Optional[List[str]] = None) -> int:
|
||||
# make_config; the fallback must match (live == backtest).
|
||||
from app_config import apply_pair_overrides # noqa: E402
|
||||
apply_pair_overrides(platform_cfg)
|
||||
if args.fixed_rr_tp is not None:
|
||||
if args.fixed_rr_tp <= 0:
|
||||
raise SystemExit("--fixed-rr-tp must be > 0")
|
||||
if (args.trail_arm is not None or args.trail_dist is not None or
|
||||
any(s.split("=", 1)[0].strip().startswith(("signals.trail",
|
||||
"signals.pb_trail"))
|
||||
for s in (args.set or []))):
|
||||
raise SystemExit(
|
||||
"--fixed-rr-tp conflicts with --trail-arm/--trail-dist/"
|
||||
"--set signals.*trail* (the flag disables trailing entirely)")
|
||||
if args.swing_sr_lookback > 0:
|
||||
platform_cfg.technical.swing_sr_lookback = args.swing_sr_lookback
|
||||
if args.trail_arm is not None:
|
||||
@@ -2369,6 +2415,7 @@ def main(argv: Optional[List[str]] = None) -> int:
|
||||
strategies_only=[s.strip() for s in args.strategies.split(",") if s.strip()],
|
||||
spread=spread,
|
||||
workers=args.workers,
|
||||
fixed_rr_tp=args.fixed_rr_tp or 0.0,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
# Fixed-RR hard-TP vs shipped trailing exit — research campaign 2026-08-05
|
||||
|
||||
**Verdict: DO NOT SHIP. The trailing exit stays.** The fixed 2R target beats the trail on
|
||||
total R in every 2024+ window but the edge is a trend-regime artifact: it inverts in the
|
||||
2021–2023 ranging years, doubles drawdown, cuts win rate 10–19 points, and doubles hold
|
||||
times (amplifying the accepted live one-position divergence). Details below.
|
||||
|
||||
## Setup
|
||||
|
||||
- Code: HEAD `fc5ce85` + the `--fixed-rr-tp` research flag (uncommitted at run time).
|
||||
The flag replaces every trade's TP with `mult × |entry − sl|` AFTER re-anchoring and
|
||||
disables trailing (run-level and per-trade PB params) — entries, stops, gates, and the
|
||||
cooldown pass are untouched, so both arms replay **trade-identical books** (verified:
|
||||
paired join on entry_time+direction matches 1:1 in every window). Only the exit policy
|
||||
differs → per-trade paired comparison.
|
||||
- Provider: Oanda (cache-warm; keys `oanda_XAU_USD_{5min,15min,1h}_{20250603_20260804,
|
||||
20240331_20250330,20210531_20260804}`). Charged cost `--spread 0.65` (real measured
|
||||
round trip); $0.30 letter-cost books derived analytically as `r + 0.35/risk` and
|
||||
verified exact against one real `--spread 0.30` run (max per-trade error 1.8e-15 —
|
||||
spread never enters trigger detection, per the owner-declined 2026-08-04 decision).
|
||||
- Control sanity gate: fresh M5 12mo control reproduced the pinned HEAD reference
|
||||
exactly — 555 trades, +98.64R, PF 1.483, maxDD −10.73R.
|
||||
- Commands (control = same without the flag):
|
||||
`venv/bin/python backtest/gpu_backtest.py --pair XAUUSD --primary-tf {5min,15min}
|
||||
--start 2025-08-04 --end 2026-08-04 --spread 0.65 [--fixed-rr-tp 2.0]
|
||||
--export-csv backtest/fixedtp_*.csv`
|
||||
OOS `--start 2024-06-01 --end 2025-03-30`; 5yr `--start 2021-08-01 --end 2026-08-04`.
|
||||
- Books pinned as `backtest/fixedtp_{m5,m15}_{12mo,oos,5yr}_{ctrl,rr20,...}.csv`
|
||||
(per-trade indicator breakdown, agreement counts, regime, vol-regime, weekday,
|
||||
MAE/MFE, bars-held — the full instructions_for_backtest record).
|
||||
|
||||
## Headline (@ $0.65)
|
||||
|
||||
| Window | Arm | n | R | PF | WR | maxDD | recov | medBars | exits |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| M5 12mo | trail | 555 | +98.64 | 1.483 | 52.4% | −10.7 | 9.2 | 28 | TRAIL 361 / SL 183 / TO 11 |
|
||||
| M5 12mo | 2R hard | 555 | +124.85 | 1.378 | 42.9% | −24.5 | 5.1 | 50 | SL 312 / TP1 210 / TO 33 |
|
||||
| M15 12mo | trail | 397 | +73.93 | 1.525 | 62.2% | −10.8 | 6.8 | 28 | TRAIL 255 / SL 135 / TO 7 |
|
||||
| M15 12mo | 2R hard | 397 | +95.95 | 1.431 | 44.3% | −11.2 | 8.6 | 39 | SL 215 / TP1 151 / TO 31 |
|
||||
| M5 OOS | trail | 86 | +9.45 | 1.332 | 53.5% | −8.2 | 1.2 | 70 | — |
|
||||
| M5 OOS | 2R hard | 86 | +18.83 | 1.407 | 45.3% | −9.7 | 1.9 | 124 | — |
|
||||
| M15 OOS | trail | 299 | +38.77 | 1.360 | 59.2% | −15.5 | 2.5 | 38 | — |
|
||||
| M15 OOS | 2R hard | 299 | +72.14 | 1.435 | 45.2% | −13.9 | 5.2 | 82 | — |
|
||||
|
||||
Halves (12mo, split 2026-02-03): M5 rr20 +67.99/+56.86 (both positive; H2 PF 1.325 vs
|
||||
ctrl 1.503), M15 rr20 +73.74/+22.21 (both positive; ctrl H2 +7.23 — the known H2-flat).
|
||||
At $0.30 all deltas keep the same sign (e.g. M5 12mo +109.0 vs +135.2).
|
||||
|
||||
So the **letter** of rule 1 passes for rr20 on both TFs. The 5yr decomposition is why it
|
||||
still must not ship.
|
||||
|
||||
## Exit migration (paired, M5 12mo; same pattern everywhere)
|
||||
|
||||
- SL→SL 183 trades: ΔR 0 (identical by construction — stops untouched).
|
||||
- TRAIL→TP1 207 trades: +248.9R → +424.5R (**+175.6R**) — winners the trail exits early.
|
||||
- TRAIL→SL 129 trades: +19.0R → −136.8R (**−155.7R**) — retraces the trail survives
|
||||
become full stop-outs.
|
||||
- TRAIL→TIMEOUT 25: +7.4R. Net = +26.2R. The whole effect is these two big flows nearly
|
||||
cancelling; which one wins is a regime property, not an exit-policy edge.
|
||||
- Trades with ctrl MFE ≥ 2R: 95; hard cap forfeits only +10.4R vs trail on M5 (the trail
|
||||
already gives ~0.75R back from peak, so a 2R cap costs little *in a trend*).
|
||||
|
||||
## Mini-grid (12mo @ $0.65) — the tell
|
||||
|
||||
| mult | M5 R / PF / DD | M15 R / PF / DD |
|
||||
|---|---|---|
|
||||
| trail | +98.6 / 1.483 / −10.7 | +73.9 / 1.525 / −10.8 |
|
||||
| 1.0R | +52.5 / 1.205 / −12.8 | +67.1 / 1.410 / −7.3 |
|
||||
| 1.5R | +80.7 / 1.263 / −21.4 | +66.9 / 1.325 / −11.2 |
|
||||
| 2.0R | +124.9 / 1.378 / −24.5 | +96.0 / 1.431 / −11.2 |
|
||||
| 2.5R | +147.7 / 1.426 / −23.6 | +110.4 / 1.469 / −15.9 |
|
||||
| 3.0R | +155.8 / 1.432 / −21.6 | +135.8 / 1.562 / −17.5 |
|
||||
|
||||
Monotone in the multiple on both TFs — "2.0" is not an optimum, it's a point on a curve
|
||||
that says *never take profit* (3R's M5 TIMEOUT bucket alone is +84.8R). That is the
|
||||
signature of a one-way trend sample, not of a superior exit rule. It also reconfirms the
|
||||
original finding that SHORT fixed targets (≤1.5R) are strictly bad (cf. RR-0.5 removal,
|
||||
`config.py:126-133`: +48R → +105R).
|
||||
|
||||
## 5yr regime decomposition (2021-08-01 → 2026-08-04 @ $0.65) — the verdict
|
||||
|
||||
M15 (real sample in every year): ctrl +127.97 PF 1.209 DD −35.8; rr20 +158.97 PF 1.170
|
||||
DD **−59.8**; rr30 +204.03 PF 1.203 DD **−98.3**.
|
||||
|
||||
| Year | n | trail | 2R hard | 3R hard |
|
||||
|---|---|---|---|---|
|
||||
| 2021 | 88 | **+4.00** | −16.09 | −17.41 |
|
||||
| 2022 | 284 | −9.54 | −7.63 | −26.74 |
|
||||
| 2023 | 227 | **+7.79** | −1.71 | −5.81 |
|
||||
| 2024 | 345 | +40.44 | +64.98 | +89.14 |
|
||||
| 2025 | 405 | +49.33 | +69.39 | +104.42 |
|
||||
| 2026 | 229 | +35.95 | +50.04 | +60.43 |
|
||||
|
||||
M5 5yr mirrors it (ctrl +128.4 PF 1.387 DD −12.8 vs rr20 +160.2 PF 1.306 DD −24.7;
|
||||
2022: −1.1 vs −5.2), though pre-2024 M5 samples are thin (12–60 trades/yr).
|
||||
|
||||
The hard target's entire outperformance lives in the 2024–2026 gold trend. In ranging
|
||||
years it flips sign exactly where the trail stays positive (M15 2021, 2023), and the
|
||||
further the target, the worse the ranging-year bleed and the deeper the DD. The 12mo,
|
||||
halves, and OOS windows all sit inside the trend era, which is why the rule-1 letter
|
||||
passes — this is precisely the within-regime-split trap rule 1's OOS clause exists to
|
||||
catch, one level up.
|
||||
|
||||
## Additional non-ship reasons
|
||||
|
||||
- **Live divergence grows with hold time.** Median hold doubles (M5 28→50 bars; OOS
|
||||
70→124). The daemon holds one position total; the backtester doesn't model that
|
||||
blocking. Longer holds = more live signals skipped that the paper book banks.
|
||||
- **WR 52→43% (M5) / 62→44% (M15), max loss streak 6→11 (M15)** — materially harder to
|
||||
operate and size.
|
||||
- Aggregate tables (session/day/regime/vol, streaks, quarterly equity, rolling-20 WR)
|
||||
computed and consistent — no bucket reverses the conclusion; both arms' 12mo profit is
|
||||
concentrated in Q1-2026 (+87.7 ctrl / +102.0 rr20).
|
||||
|
||||
## Status
|
||||
|
||||
- `--fixed-rr-tp` stays as a documented research-only flag (default off; conflicts with
|
||||
`--trail-arm/--trail-dist/--set signals.*trail*` are hard errors). Module 22 tests
|
||||
(5) pin the override math, TP fill + spread, SL-first tie, PB-trail-disable, and CLI
|
||||
guards. Full suite: 346 green.
|
||||
- Re-open only if gold's regime turns and a *regime-conditional* exit (trail in range,
|
||||
target in trend) is proposed with a pre-registered mechanism — a plain fixed ratio is
|
||||
now a closed hypothesis at every multiple in {0.5, 1.0, 1.5, 2.0, 2.5, 3.0}.
|
||||
Reference in New Issue
Block a user