CuPy from pip had never compiled a kernel on the dev box (no CUDA_PATH for the splayed CUDA-12 wheel layout); the probe swallowed the error and every book to date was built on the NumPy path under a "GPU backtester" name. - backtest/gpu_kernels.py (new): lazily probed, never-silent BACKEND state (CUDA_PATH pointed at the nvidia-cuda-runtime-cu12 header wheel; the probe compiles a kernel and keeps the failure reason), --backend auto|gpu|cpu and FB2_BACKEND, and three CUDA kernels compiled with --fmad=false: ewm_batch (the _gpu_ewm_alpha recurrence, one thread per series), roll_mean_var (pandas 3.0.2 roll_mean/roll_var replica, Kahan + Welford + unstable recompute), resolve_trades (trailing and static exits, one thread per trade). - gpu_backtest.py: precompute_indicators and simulate_trades dispatch to the GPU twins or to the untouched NumPy paths; caches are host NumPy and device memory is released before the replay workers fork; the Backend: line names the device or the fallback reason. - test_all.py Module 36 (7 tests): a visible CUDA device must never fall back silently; kernels vs their CPU references on synthetic edge cases; backend selection; import guards (cupy banned from the live roots). Suite 536. - CLAUDE.md, instructions_for_backtest.txt, research note with the evidence. Verification: all five reference MD5s reproduced on the GPU and with --backend cpu (ten runs, GPU cache fallbacks 0); precompute GPU == CPU on all 42 cached windows (2.34 M bars); suite green with GPU, with CUDA_VISIBLE_DEVICES="" and with FB2_BACKEND=cpu; --workers 1 and --record pairs equal. The per-bar replay stays the live Python signal code on CPU workers by design; wall time is roughly unchanged (precompute ~3x faster). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
9.0 KiB
GPU backend made real — 2026-09-18
Behaviour-neutral change. No signal logic, weight, gate or exit rule moved; the proof is the ten
MD5s below (five reference books on the GPU, five with --backend cpu), all equal to the pins in
CLAUDE.md section 9. The books therefore carry the same trades, R, win rate, profit factor and
drawdown, in both halves and at both cost conventions, as the pinned books do.
1. What was wrong
backtest/gpu_backtest.py probed CuPy at import with a cumsum and swallowed any exception.
On the dev box the first kernel compile raised
RuntimeError: Failed to auto-detect CUDA root directory. Please specify `CUDA_PATH` environment
variable if you are using CUDA versions not yet supported by CuPy.
CuPy 14.0.1 from pip (cupy-cuda12x) was installed with only the nvidia-cuda-nvrtc-cu12
wheel; the CUDA-12 pip layout is splayed (no /usr/local/cuda, no nvcc), so CuPy's
_get_cuda_path() returned None and NVRTC could not find the headers. Every run since the
Oanda re-pin logged Backend: NumPy CPU fallback; every research report in this folder records
it. Independently of that, almost nothing in the file was written for the GPU: the EWM
precompute was a pure-Python loop, Bollinger was pandas rolling, and the trailing-stop
resolver (the exit mode of every reference book) was a per-trade per-bar Python loop. The only
CuPy code was a first-hit reduction in the static SL/TP branch, which the reference books never
execute.
2. What shipped
nvidia-cuda-runtime-cu12==12.9.79installed into the dev venv (headers only, notrequirements.txt; same status ascupyandpyarrow).backtest/gpu_kernels.py: the backend state (BACKEND, probed lazily so importing the backtester oranalyze_signal_log.pycreates no CUDA context; the probe compiles a kernel, and a failure keeps its reason),CUDA_PATHpointed at the header wheel when unset, and three CUDA kernels compiled with--fmad=false:ewm_batch: one thread per series, the exact recurrence of_gpu_ewm_alpha(seed at the first non-NaN value, NaN carries the state without advancing themin_periodscounter).roll_mean_var: pandas 3.0.2roll_mean(Kahan add/remove, sign count, consecutive-same- value guard) androll_var(Welford + Kahan,InvCondTolunstable recompute); std as pandas'zsqrt.resolve_trades: one thread per trade; trailing mode mirrors_simulate_trailingline by line, static mode mirrors the SL-first/TP1/TIMEOUT branch, including the "NaN close after a TIMEOUT" bookkeeping; R and MFE/MAE on the device.
backtest/gpu_backtest.py:precompute_indicatorsdispatches to_precompute_indicators_gpu(CuPy elementwise ops in the original order, two EWM launches and one rolling launch per timeframe, one transfer to host at the end, host-only cache asserted before the fork) or to the untouched NumPy path;simulate_tradesdispatches to_resolve_trades_gpuor to the untouched CPU paths; pooled device memory is released before the workers are forked;--backend auto|gpu|cpu(envFB2_BACKEND); theBackend:line names the device or the fallback reason.test_all.pyModule 36 (7 tests): a visible CUDA device must never fall back silently; the three kernels against their CPU references on synthetic edge cases (NaN head, interior NaN, flat window, cancellation spike, gaps, NaN bars, zero risk, per-trade trail overrides);--backend/FB2_BACKEND/cpu_only(); import guards (cupyis now banned from the live roots by the Module 32 AST guard as well).
Why the kernels are sequential per series: EWM and rolling statistics are order-dependent
floating point. A parallel scan or a two-pass variance changes the last bits, and the file's own
history shows what that does (a cumsum Bollinger shortcut moved bb_position by up to 8e-3 and
crossed signal thresholds). The GPU's gain here is the batch across series and across trades,
not per-series speed: on a single very long series the sequential kernel is slower than pandas'
C loop.
3. Evidence
Environment: NVIDIA GeForce RTX 4070 SUPER, driver 615.71.09 (CUDA 13.4 UMD), CuPy 14.0.1
(cupy-cuda12x), nvidia-cuda-nvrtc-cu12 12.9.86, nvidia-cuda-runtime-cu12 12.9.79, NumPy
2.2.6, pandas 3.0.2, Python 3.12, 16 CPUs. Code: this working tree on branch sql-integration
on top of 1f4741b.
3.1 Precompute equality on every cached window
GPU vs CPU precompute_indicators, every IndicatorCache field compared with
np.array_equal(..., equal_nan=True):
precompute GPU == CPU on 42/42 windows, 2342009 bars total; gpu 13.71s (incl. cold JIT) vs cpu 40.18s
3.2 Test suite, three ways
| Run | Result | Module 36 note |
|---|---|---|
| default (GPU) | RESULTS: 536 passed, 0 failed, 536 total |
kernels exercised |
CUDA_VISIBLE_DEVICES="" |
RESULTS: 536 passed, 0 failed, 536 total |
NumPy CPU fallback (CUDARuntimeError: cudaErrorNoDevice: ...) printed |
FB2_BACKEND=cpu |
RESULTS: 536 passed, 0 failed, 536 total |
NumPy CPU fallback (forced: ...) printed |
3.3 Reference books on both backends
Harness: venv/bin/python backtest/gpu_backtest.py --pair XAUUSD --spread 0.65 --workers 12 --primary-tf <tf> --start <s> --end <e> --export-csv <out> (factory config; no --set,
--quality, --strategies, --confirm-tfs or --export-features), plus --backend cpu for
the CPU column. GPU cache fallbacks: 0 on every run.
| Book | Window | Backend | Precompute | Replay | Trade sim | Wall | MD5 | Pinned |
|---|---|---|---|---|---|---|---|---|
| M5 W2 | 2024-09-05 → 2026-09-05 | CuPy GPU | 1.31 s | 112.0 s | 0.31 s | 114.8 s | 49f8e602fc6ae769713c94ef7b257810 |
✓ |
| M5 W2 | NumPy CPU (--backend cpu) |
3.74 s | 112.6 s | 0.22 s | 117.0 s | 49f8e602fc6ae769713c94ef7b257810 |
✓ | |
| M15 W2 | 2024-09-05 → 2026-09-05 | CuPy GPU | 1.29 s | 34.6 s | 0.33 s | 37.2 s | b4aa07e33e17e23774c486a6900d673b |
✓ |
| M15 W2 | NumPy CPU | 3.69 s | 34.6 s | 0.28 s | 39.0 s | b4aa07e33e17e23774c486a6900d673b |
✓ | |
| M15 OOS | 2024-06-01 → 2025-03-30 | CuPy GPU | 0.63 s | 14.0 s | 0.05 s | 15.6 s | 9fd624fb387c36052122bd1830cf9c32 |
✓ |
| M15 OOS | NumPy CPU | 1.70 s | 13.9 s | 0.09 s | 16.1 s | 9fd624fb387c36052122bd1830cf9c32 |
✓ | |
| H1 W3 | 2023-09-05 → 2026-09-05 | CuPy GPU | 0.61 s | 11.3 s | 0.07 s | 13.0 s | 7b848c917dcb5c371ecbb9f8f7292278 |
✓ |
| H1 W3 | NumPy CPU | 1.68 s | 11.1 s | 0.07 s | 13.3 s | 7b848c917dcb5c371ecbb9f8f7292278 |
✓ | |
| H4 W3 | 2023-09-05 → 2026-09-05 | CuPy GPU | 0.20 s | 20.0 s (1 worker) | 0.13 s | 21.2 s | 186e677fbbd53cca735c94094c23ce05 |
✓ |
| H4 W3 | NumPy CPU | 0.46 s | 19.9 s (1 worker) | 0.02 s | 20.7 s | 186e677fbbd53cca735c94094c23ce05 |
✓ |
Backend: lines verbatim: CuPy GPU (NVIDIA GeForce RTX 4070 SUPER, cupy 14.0.1) and
NumPy CPU fallback (forced: --backend cpu / FB2_BACKEND=cpu). The GPU precompute figures are
warm (the kernel cache under ~/.cupy/kernel_cache was already populated; a cold first run adds
roughly a second of JIT once per machine). The trade-simulation figures on the GPU include the
host↔device transfers and the launch of a kernel that runs a few hundred threads; on these trade
counts it is not faster than the CPU loop, which is expected. The before/after picture is exactly
as planned: the precompute is about 3× faster, everything else is unchanged, and the replay
through the live signal code (the same bars/s on both rows) is where the wall time lives.
3.4 Other checks
- Single worker on the GPU (
--workers 1, H1 W3): MD57b848c917dcb5c371ecbb9f8f7292278, equal to the 12-worker book (the CUDA context exists in the parent before the fork; the workers never touch it). --recordpair on the GPU (H4 W3): control186e677f…== recorded186e677f…==manifest.book_csv_md5; the manifest recordsbackend = CuPy GPU (NVIDIA GeForce RTX 4070 SUPER, cupy 14.0.1)andcache_fallbacks = 0.CUDA_VISIBLE_DEVICES="" ... --backend gpuexits withRuntimeError: --backend gpu requested but CUDA is unavailable: CUDARuntimeError: cudaErrorNoDevice ...; the same run inautomode logs the warning andBackend: NumPy CPU fallback (CUDARuntimeError: cudaErrorNoDevice: no CUDA-capable device is detected).analyze_signal_log.py --stored-candles(which reusessimulate_trades) on the importedlogs/signals.db: the report is line-for-line identical on the GPU and withFB2_BACKEND=cpu.nvidia-smi --query-compute-appsduring the M5 precompute listed the backtester process on the device (184 MiB).- Scratchpad experiments that preceded the implementation: the EWM kernel bit-identical to
_gpu_ewm_alphaon 9 series over the 154k-bar M5 Close (95 ms incl. JIT vs 1.29 s Python); the rolling kernel bit-identical to pandas on all 84 series of the 42 cached windows. git diff --stattouchesbacktest/gpu_backtest.py,test_all.py,CLAUDE.md,backtest/instructions_for_backtest.txtand addsbacktest/gpu_kernels.pyand this note;engine.py,signal_aggregator.pyandrequirements.txtare untouched.