Owner decisions: research only (live scans stay on the CPU code), gpu_backtest.py stays the base reference, every research flag supported, the one exemption from instructions item 6 recorded in backtest/instructions_for_backtest.txt. - backtest/cuda_signal_kernels.py: the CUDA decision stage. K1 = engine.py + the GPUTechnicalEngine overrides (one thread per bar), K2 = analyze_from_dataframes end to end incl. gates 6b-6i, the four setup generators and the regime filter (one thread per primary bar), K3 = position_sizing.build_features, K4 cooldown, K5 prefix median, K6 recorder bar rows. Config packer with generated #defines; leaf module; --fmad=false; Python evaluation order kept (ternary min/max, pairwise sums, Neumaier sum(), exact round(x, 2)). - backtest/cudaBasedBacktesting.py: same CLI, cache files and writers as the reference; the host turns kernel codes into the exact strings; no CPU path. - test_all.py Module 37 (16 tests): every stage pinned against the live code; suite 552 / 552 on the GPU, without CUDA and with FB2_BACKEND=cpu. - Proof: five reference MD5s, four 6-month acceptance books (M5/M15, $0.65/$0.30), M15 OOS --export-features and --record --record-bars tables, ten research-flag variants, and the owner's five-year M5 and M15 books, all byte-identical to gpu_backtest.py. Wall time 0.8-6.6 s per book against 13-353 s. - CLAUDE.md sections 1, 3, 6, 7, 9, 10, 11 and the instructions addendum: any signal change is now a parity event for the twin. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
18 KiB
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.pystays 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-featuresand--fixed-rr-tpall 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_<tf>_20260115_20260918) and both scripts read the same files afterwards. backtest/instructions_for_backtest.txtitem 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 #defines, 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 <tf> --start 2026-03-18 --end 2026-09-18 --spread <sp> --workers 12 --export-csv <out> 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)
fb2_round2/round1against Pythonround(x, 2)/round(x, 1)on 400k doubles including every.xx5half in ±300 and signed zero; without CUDA the rule is checked in exact arithmetic.- 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_rr1.0, the six-regime × nine-indicator table,FB2_EMA_LOread 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#defines are generated from the lists. - K1 against
GPUTechnicalEngine.analyzeon 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. - The arithmetic twins: numpy pairwise sums and
Series.meanfor n = 1…600,nanmeanwith 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 ofgpu_kernels._ROLL_SRC. align_cutagainst the replay'ssearchsortedrule on gapped, late-starting M15/H1/H4/D1 frames for M5 and H1 primaries (cut == 0included).host_flagsagainst_is_xau_blocked_by_calendarplus 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.- K2 against
analyze_from_dataframeson 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 mixedtrendingregime 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. - The device chart-pattern detector against
detect_chart_patternwith 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. - K4 against
_apply_cooldown(cooldown 0 / 1 / 5 / 17, empty, a dense run). Traderows, the 65 features, the recorder snapshots and the per-bar float32 matrix against a real_replay_spanover the whole M5 fixture with the pullback path re-enabled (XAU_PB, XAU_CP and GENERIC trades;dataclasses.asdictfield by field, exact floats, tz-aware entry times, the CSV frame, dtypes and bytes with the research columns).- End to end:
cudaBasedBacktesting.mainagainstgpu_backtest.mainon 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-barsMD5 equal and the four parquet tables equal. - 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 togpu_backtest.py, the largest supported values pass; the parser is the reference script's;require_gpu_or_exit("gpu")and theBackend:text on a working box. - Source guards: the script never calls
analyze_from_dataframesor_replay_span, never imports cupy or a live writer; the kernel module stays a leaf (noengine,signal_aggregator,gpu_backtest,replay_record, live writer or ML import at module level), pinsoptions=KERNEL_OPTIONS(--fmad=false) and contains nofmin(/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.
--workersis 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.pyorgpu_kernels.pychanged.git diff --stattouchestest_all.py,CLAUDE.mdandbacktest/instructions_for_backtest.txtand adds the two new modules and this note.