From 525a1cd1c12c7e41bf419a91708847db2d02fa28 Mon Sep 17 00:00:00 2001 From: Harwinder Sekhon Date: Thu, 6 Aug 2026 22:20:43 -0400 Subject: [PATCH] research: --fixed-rr-tp backtest flag + 2026-08-05 verdict (NOT shipped) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). Claude-Session: https://claude.ai/code/session_017mbXs9HRawZhD1uZ3ze7zQ --- CLAUDE.md | 2 + backtest/gpu_backtest.py | 49 +++++++++- backtest/research/fixedtp_2026-08-05.md | 123 ++++++++++++++++++++++++ 3 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 backtest/research/fixedtp_2026-08-05.md diff --git a/CLAUDE.md b/CLAUDE.md index 6f36c7a..5ac033f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,6 +24,8 @@ python backtest/gpu_backtest.py --pair XAUUSD --start 2025-01-01 --end 2025-06-0 --primary-tf 15min --confirm-tfs 1h,4h # key flags: --spread --quality A+,A,B,C --strategies --workers --max-hold-bars # --warmup-bars --cooldown-bars --no-cache --export-csv out.csv --detail +# --fixed-rr-tp N (research-only: hard TP at N×risk, trail off; verdict +# 2026-08-05: NOT shipped — backtest/research/fixedtp_2026-08-05.md) ``` **`test_all.py` is NOT pytest** — it uses a custom `@test("name")` decorator that runs each test immediately at import; don't grep for `def test_` or try `pytest test_all.py`. Run the whole file; it prints per-module results and exits non-zero on failure. There is no way to run a single test short of commenting modules out. Tests must stay green; every new gate/knob needs tests. When patching weekend logic in tests, patch `providers.base._is_forex_weekend` — but know there are THREE independent weekend implementations (`providers/base.py`, `signal_aggregator.py`, and an inline copy in `backtest/gpu_backtest.py:_is_xau_blocked_by_calendar`); patching the provider one does not reach the other two. diff --git a/backtest/gpu_backtest.py b/backtest/gpu_backtest.py index 6982027..213144d 100644 --- a/backtest/gpu_backtest.py +++ b/backtest/gpu_backtest.py @@ -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( diff --git a/backtest/research/fixedtp_2026-08-05.md b/backtest/research/fixedtp_2026-08-05.md new file mode 100644 index 0000000..0e51bc6 --- /dev/null +++ b/backtest/research/fixedtp_2026-08-05.md @@ -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}.