# Full-GPU research twin of the backtester — 2026-09-18 Behaviour-neutral by construction and by proof. `backtest/cudaBasedBacktesting.py` runs every stage of a backtest on the GPU, including the per-bar decision that `gpu_backtest.py` still runs through the live Python signal code. Nothing in the signal logic, the weights, the gates or the exit rules moved; the proof is that both scripts export byte-identical books (same CSV MD5) on every window and every research flag tried, listed in section 3. ## 1. Owner decisions - Research and backtesting only. Live scans stay on the CPU signal code; the Telegram bot, the CLI and the dashboard never import the new files (Module 37 sweeps the live roots). - `gpu_backtest.py` stays in the tree as the **base reference**. The twin must reproduce its exported book byte for byte for the same arguments; the MD5 of the CSV is the acceptance gate. - `--record`, `--record-bars`, `--export-features` and `--fixed-rr-tp` all work on the GPU path. - Acceptance: 6-month M5 and M15 books, window 2026-03-18 → 2026-09-18, both scripts, identical CSV MD5 at $0.65 (headline) and $0.30 (comparability). That window was not cached; the first control run fetched M5/M15/H1 from Oanda (padded 62 days, `oanda_XAU_USD__20260115_20260918`) and both scripts read the same files afterwards. - `backtest/instructions_for_backtest.txt` item 6 ("replays go through the real signal code, never a re-implementation") is waived for this ONE research script; the dated addendum in that file records the exemption and the new obligation: any signal change is a parity event, re-prove the MD5 with both scripts before a number from the twin counts as evidence. ## 2. What shipped | File | Role | |---|---| | `backtest/cudaBasedBacktesting.py` (1,028 lines) | Same CLI as the reference (it reuses `_parse_args`), same cache files, same report and CSV writers. Reuses by import everything that is not the replay: the loader and its cleaning, `precompute_indicators` (GPU), the trade resolver (GPU), `_reanchor_entries_to_raw`, `_apply_fixed_rr_tp`, `compute_metrics`, `print_report`, `trades_to_dataframe`, `replay_record.write_run`. Replaces `_replay_span` + the worker pool + `_apply_cooldown` with kernel launches and host builders that turn the kernels' numeric codes into the exact strings (indicator descriptions, warnings, reasons) with the same f-string templates as `engine.py` / `signal_aggregator.py`, then call the real `SignalAggregator._clean_accepted_cp_warnings` and `_drop_stale_spread_gate_vetoes`. No CPU path: `--backend cpu`, `FB2_BACKEND=cpu` or a failed probe exit with a message that points to `gpu_backtest.py`; configurations beyond the compile-time limits (more than 4 confirmation frames, divergence lookback ≥ 64, swing lookback ≥ 256, `cp_lookback` ≥ 128, a big-candle window over 500 bars) are refused the same way. | | `backtest/cuda_signal_kernels.py` (2,914 lines, most of it CUDA C in strings) | K1 `fb2_analyze_tf` (one thread per bar of one timeframe; the nine `GPUTechnicalEngine` overrides plus the live engine code they do not cover: divergence, structure S/R, candlestick, regime, regime-aware RSI, composite, the `len < 30` empty analysis, `ema_mode="2ema"`). K2 `fb2_aggregate` (one thread per primary bar; `analyze_from_dataframes` end to end: the multi-timeframe merge, bonuses, penalties in the post-sort decay order with the 30 cap, exhaustion block, signal, grade, gates 6b–6i, the XAU_ATR / GENERIC / XAU_PB / XAU_CP generators, the NEUTRAL-at-grade-C direction, `_apply_regime_filter_live`, plus the replay's quality / strategy filters, next-open entry and distance-preserving re-anchor). K3 `fb2_features` (the 65 `position_sizing.build_features` columns per accepted trade). K4 cooldown (single thread, the only cross-bar rule). K5 prefix median (the snapshot's `spread_estimate`). K6 recorder bar rows (the 160-column float32 row). The config packer (`PARAMS_F` / `PARAMS_I` name lists → generated `#define`s, so a name cannot drift), the classification table of every config field the kernels do not read, and thin test launches of the device helpers. Compiled with `--fmad=false`; imports only numpy, `gpu_kernels`, `config`, `chart_patterns` (pattern priority order) and `position_sizing` constants. | | `test_all.py` Module 37 (16 tests) | Section 4. | | `backtest/instructions_for_backtest.txt` | The 2026-09-18 CUDA-twin addendum. | What stays on the host, on purpose: loading and cleaning the parquet cache; the calendar and session flags (pandas time-zone arithmetic over the whole index: NY weekend, UTC weekday, blocked close hours, and the three clocks the gates use: bar label for gate 6b, bar close for gate 6g3 and XAU_CP, decision time for the replay skip); the text; the writers. Rules that keep the twins bit-identical (they cost most of the debugging time and are now pinned by tests): doubles everywhere; Python `min()` / `max()` as argument-order ternaries, never `fmin` / `fmax`; every sum in Python's iteration order; numpy's pairwise summation (8 accumulators, 128-blocks) for `np.mean` / `np.nanmean`; Python 3.12's Neumaier-compensated builtin `sum()` for the multi-timeframe score mean; pandas' online rolling mean and EWM recurrences copied from `gpu_kernels.py` for the restarted tails the features use; `round(x, 2)` reproduced exactly with an error-free product (the host additionally re-checks Python's `round()` on the device doubles of every accepted trade); `atr_percentile = count / 50 * 100` (not `count * 2`: 15/50·100 = 30.000000000000004 flips "low" to "normal"); the as-of alignment compares int64 nanoseconds. ## 3. Evidence Environment: NVIDIA GeForce RTX 4070 SUPER, CuPy 14.0.1 (`cupy-cuda12x` with the `nvidia-cuda-runtime-cu12` header wheel), NumPy 2.2.6, pandas 3.0.2, Python 3.12, 16 CPUs. Code: this working tree on branch `sql-integration` on top of `bac11d8`. `Backend:` lines verbatim: reference `CuPy GPU (NVIDIA GeForce RTX 4070 SUPER, cupy 14.0.1)`, twin `CuPy GPU (NVIDIA GeForce RTX 4070 SUPER, cupy 14.0.1)`; both log `GPU cache fallbacks: 0` (the twin's line adds "no per-bar recompute path in cudaBasedBacktesting"). ### 3.1 Acceptance: 6 months, both scripts, both costs Harness: `venv/bin/python backtest/gpu_backtest.py --pair XAUUSD --primary-tf --start 2026-03-18 --end 2026-09-18 --spread --workers 12 --export-csv ` for the control and `venv/bin/python backtest/cudaBasedBacktesting.py` with the same arguments (no `--workers`) for the twin; factory config, no overrides. | Primary | Cost | Control MD5 | Twin MD5 | Equal | Control wall | Twin wall | |---|---|---|---|---|---|---| | M5 | $0.65 | `86111c36c1a07e543a65f0cd09d56ebf` | `86111c36c1a07e543a65f0cd09d56ebf` | ✓ | 51.8 s (replay 43.8 s) | 1.27 s | | M5 | $0.30 | `e70d025205844ecedc2b9e9c08f38f77` | `e70d025205844ecedc2b9e9c08f38f77` | ✓ | 47.1 s (replay 45.9 s) | 1.65 s | | M15 | $0.65 | `b8c980833f1a32bd59930bd037c75b89` | `b8c980833f1a32bd59930bd037c75b89` | ✓ | 20.4 s (replay 18.7 s) | 1.24 s | | M15 | $0.30 | `e66b5fbdaace1d3fda4a76a0e2d47d69` | `e66b5fbdaace1d3fda4a76a0e2d47d69` | ✓ | 14.8 s (replay 13.5 s) | 1.50 s | Because the CSVs are identical the figures below are the same from either script (`NO_DATA` rows excluded; halves split at 2026-06-18). They are reported because the instructions require them, not as a validation: nothing changed, and the window is 6 months (rule 1 needs 12). | Primary | Cost | Segment | Trades | Total R | Win rate | PF | Max DD (R) | |---|---|---|---|---|---|---|---| | M5 | $0.65 | whole | 283 | +41.67 | 56.5% | 1.42 | 10.1 | | M5 | $0.65 | first half | 146 | +29.58 | 58.9% | 1.64 | 6.6 | | M5 | $0.65 | second half | 137 | +12.09 | 54.0% | 1.23 | 8.3 | | M5 | $0.30 | whole | 283 | +46.38 | 57.2% | 1.48 | 8.4 | | M5 | $0.30 | first half | 146 | +32.07 | 60.3% | 1.71 | 6.4 | | M5 | $0.30 | second half | 137 | +14.30 | 54.0% | 1.28 | 7.4 | | M15 | $0.65 | whole | 238 | +16.35 | 60.1% | 1.19 | 14.9 | | M15 | $0.65 | first half | 120 | +14.09 | 63.3% | 1.37 | 7.6 | | M15 | $0.65 | second half | 118 | +2.26 | 56.8% | 1.05 | 14.4 | | M15 | $0.30 | whole | 238 | +19.20 | 60.9% | 1.23 | 13.8 | | M15 | $0.30 | first half | 120 | +15.37 | 65.0% | 1.41 | 7.1 | | M15 | $0.30 | second half | 118 | +3.82 | 56.8% | 1.08 | 13.8 | By generator at $0.65: M5 GENERIC 163 trades +42.4R, XAU_CP 104 −2.7R, XAU_ATR 16 +2.0R (BUY 244 +25.7R, SELL 39 +16.0R); M15 GENERIC 149 +26.3R, XAU_ATR 89 −10.0R (BUY 144 +9.7R, SELL 94 +6.7R). The M15 second half is the same weak stretch the 2026-09-17 live review saw; no decision follows from a 6-month window. ### 3.2 The five reference books (kept cache, `--spread 0.65`, factory config) | Book | Window | Twin MD5 | Pinned (CLAUDE.md §9) | Twin wall | |---|---|---|---|---| | M5 W2 | 2024-09-05 → 2026-09-05 | `49f8e602fc6ae769713c94ef7b257810` | ✓ | 2.4 s (reference 114.8 s) | | M15 W2 | 2024-09-05 → 2026-09-05 | `b4aa07e33e17e23774c486a6900d673b` | ✓ | 2.3 s (37.2 s) | | M15 OOS | 2024-06-01 → 2025-03-30 | `9fd624fb387c36052122bd1830cf9c32` | ✓ | 1.5 s (15.6 s) | | H1 W3 | 2023-09-05 → 2026-09-05 | `7b848c917dcb5c371ecbb9f8f7292278` | ✓ | 1.3 s (13.0 s) | | H4 W3 | 2023-09-05 → 2026-09-05 | `186e677fbbd53cca735c94094c23ce05` | ✓ | 0.8 s (21.2 s) | Reference wall times are the GPU-backend rows of `gpu_backend_2026-09-18.md`. On the twin the decision stage itself is milliseconds (M5 W2: K1 218,088 bars of three timeframes in 0.020 s, K2 141,819 primary bars in 0.002 s); what remains is loading and cleaning the parquet files, the indicator precompute (1.3 s on the two-year M5 window), the host formatting of the accepted trades and the CSV write. ### 3.3 Research flags and knobs (M15 OOS window unless stated; both scripts, `--spread 0.65`) | Variant | Rows | Twin MD5 (prefix) | Equal | |---|---|---|---| | `--export-features` (130 columns) | 355 | `6239d197` | ✓ | | `--record DIR --record-bars` | 355 | book `9fd624fb`; `trades` (355 × 138), `indicators` (8,120 × 15), `events` (6,902 × 14), `bars` (12,315 × 172) parquet tables equal via `DataFrame.equals`; manifests differ only in `cli`, `created_at`, `replay_seconds`, `workers` | ✓ | | `--fixed-rr-tp 2.0` | 355 | `95a7d2ab` | ✓ | | `--quality A+,A` | 249 | `1883c346` | ✓ | | `--strategies XAU` | 3 | `c07a4025` | ✓ | | `--cp` (all six chart patterns on M15) | 396 | `7ff51617` | ✓ | | `--big-candle 3.0` | 104 | `76f76f3b` | ✓ | | `--set backtest.regime_filter=true` (H4 ADX gate, a fourth frame) | 179 | `0a65af6b` | ✓ | | `--set technical.ema_mode=2ema` | 368 | `faaa7e86` | ✓ | | M5, `--set xau_strategy.m5_pullback_enabled=true --quality A+,A,B,C,D` | 279 | `1811117a` | ✓ | | `--trail-arm 0.5 --trail-dist 0.9 --cooldown-bars 3 --max-hold-bars 150` | 414 | `9665d753` | ✓ | | H1, `--swing-sr-lookback 80 --warmup-bars 300` | 75 | `f8ef2b9f` | ✓ | One variant could not run on either script: H1 with `--confirm-tfs 15min,4h,1d` on the OOS window needs a D1 file padded 1,400 days (2020-08-01) that is not cached, and Oanda returned an empty page for that start; both scripts fail identically in `fetch_range`. Not a parity issue. ### 3.4 Test suite, three ways (final tree) | Run | Result | Wall | Module 37 note | |---|---|---|---| | default (GPU) | `RESULTS: 552 passed, 0 failed, 552 total` | 116 s | every device test exercised | | `CUDA_VISIBLE_DEVICES=""` | `RESULTS: 552 passed, 0 failed, 552 total` | 30 s | `CUDA unavailable here: NumPy CPU fallback (CUDARuntimeError: cudaErrorNoDevice: no CUDA-capable device is detected)` printed; device tests print a notice | | `FB2_BACKEND=cpu` | `RESULTS: 552 passed, 0 failed, 552 total` | 31 s | `NumPy CPU fallback (forced: --backend cpu / FB2_BACKEND=cpu)` printed; the twin's refusal of a cpu-forced backend is exercised for real | 536 tests before this change, 16 added by Module 37. ## 4. Module 37 (what each test pins) 1. `fb2_round2` / `round1` against Python `round(x, 2)` / `round(x, 1)` on 400k doubles including every `.xx5` half in ±300 and signed zero; without CUDA the rule is checked in exact arithmetic. 2. Parameter packing round-trips every decision leaf of the four factories (M5 blocked hours with 9 and without 21, M15 SELL hours 15/17 and the RSI × 0.5 table, H1 empty SELL quality, M15 `tp_floor_rr` 1.0, the six-regime × nine-indicator table, `FB2_EMA_LO` read once with the same fallback); every field of the four config dataclasses is either packed or classified host-only, so a new field fails the suite until it is placed; the `#define`s are generated from the lists. 3. K1 against `GPUTechnicalEngine.analyze` on every bar of a 600-bar fixture (doji, hammer, shooting star, engulfing, marubozu, a flat stretch, plateaus) for 5min/15min/1h/4h under their factories, partial-EMA off, `2ema`, classic RSI, disabled indicators and a 150-bar frame: valid flag, composite, regime, volatility, every indicator's value / signal / confidence / description / effective weight / weighted score, key levels, pattern signals. 4. The arithmetic twins: numpy pairwise sums and `Series.mean` for n = 1…600, `nanmean` with NaNs, EWM and rolling means restarted on tails, Wilder ATR on tails, `_ext_vs_sma`, the prefix median with ties, CPython float floor division; and the rolling helper text is the prefix of `gpu_kernels._ROLL_SRC`. 5. `align_cut` against the replay's `searchsorted` rule on gapped, late-starting M15/H1/H4/D1 frames for M5 and H1 primaries (`cut == 0` included). 6. `host_flags` against `_is_xau_blocked_by_calendar` plus the session block bar by bar across both 2026 DST shifts, an H4 primary (label hour ≠ close hour), the day filter, and each filter switched off. 7. K2 against `analyze_from_dataframes` on every analysed bar: the M5 factory over a 21-day synthetic fixture (4,046 bars: all six grades, GENERIC and XAU_CP setups, both directions, the mixed `trending` regime with both score signs, 23 distinct warnings); the M5 research knobs (all six chart patterns, big-candle 3.0, the H4 regime filter, `FB2_EMA_LO=0.9`, grades to D; every second bar); the M15 factory (1,182 bars, 62 candidates, XAU_ATR and GENERIC, the SELL, exhaustion-block and spread gates); the H1 factory (shorts blocked) and the H4 factory with a D1 confirmation shorter than 30 bars on a 30-day fixture. Compared per bar: score, confidence, signal, grade, agreement, exhaustion count, momentum fading, penalty total and tags, the full warning and cleaned-warning lists, both reason lists, price, spread estimate, regime, volatility, the setup (strategy, direction, entry, stop, targets, rounded RR, pattern) and the replay's candidate fields. 8. The device chart-pattern detector against `detect_chart_pattern` with all six patterns on the fixture and on its mirror image (bear patterns), zero ATR included; the device big-candle detector against `_detect_big_candle` (index and ratio) with injected abnormal bars. 9. K4 against `_apply_cooldown` (cooldown 0 / 1 / 5 / 17, empty, a dense run). 10. `Trade` rows, the 65 features, the recorder snapshots and the per-bar float32 matrix against a real `_replay_span` over the whole M5 fixture with the pullback path re-enabled (XAU_PB, XAU_CP and GENERIC trades; `dataclasses.asdict` field by field, exact floats, tz-aware entry times, the CSV frame, dtypes and bytes with the research columns). 11. End to end: `cudaBasedBacktesting.main` against `gpu_backtest.main` on the fixture written to a temporary cache directory (M15, two weeks): plain book MD5 equal; `--fixed-rr-tp 2.0 --export-features --record DIR --record-bars` MD5 equal and the four parquet tables equal. 12. Refusals: `--backend cpu`, a cpu-forced backend and a failed probe exit before the cache is touched; each compile-time limit is refused with a message naming the knob and pointing to `gpu_backtest.py`, the largest supported values pass; the parser is the reference script's; `require_gpu_or_exit("gpu")` and the `Backend:` text on a working box. 13. Source guards: the script never calls `analyze_from_dataframes` or `_replay_span`, never imports cupy or a live writer; the kernel module stays a leaf (no `engine`, `signal_aggregator`, `gpu_backtest`, `replay_record`, live writer or ML import at module level), pins `options=KERNEL_OPTIONS` (`--fmad=false`) and contains no `fmin(` / `fmax(`; the shared code tables equal the recorder's; the live roots import neither new module. On the GPU the module takes about 85 s, almost all of it the CPU references (about 5 ms per bar through the live signal code). Without CUDA the device tests print a notice and the module takes about a second. ## 5. Honest notes - The twin is a second implementation of the decision stage. The MD5 gate proves equality on the windows and flags tried, not in general; a future signal change that is not mirrored in the kernels will make the twin drift silently. That is why the instructions addendum and CLAUDE.md section 7 make every signal change a parity event, and why the reference script stays. - Speed comes from the batch, not from the per-bar work: the decision kernels run one thread per bar over the whole window at once. The remaining wall time (about a second per book) is parquet loading, cleaning, the precompute and the host formatting of the accepted trades. - `--workers` is accepted and ignored (logged); the twin never forks. - Nothing in `engine.py`, `signal_aggregator.py`, `chart_patterns.py`, `position_sizing.py`, `replay_record.py`, `gpu_backtest.py` or `gpu_kernels.py` changed. `git diff --stat` touches `test_all.py`, `CLAUDE.md` and `backtest/instructions_for_backtest.txt` and adds the two new modules and this note.