backtest: real CuPy GPU backend (kernels are bit-identical CPU twins); books byte-identical on both backends (2026-09-18)

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).
This commit is contained in:
Harwinder Sekhon
2026-09-18 10:39:55 -04:00
parent ad14ea4fed
commit 02589cbb88
6 changed files with 1487 additions and 70 deletions
+300 -1
View File
@@ -10998,7 +10998,7 @@ def _():
def _():
"""The corpus is trained on offline in venv/; anything the live signal
path can reach must stay numpy-only (requirements.txt is the contract)."""
banned = {"sklearn", "scipy", "lightgbm", "torch", "transformers"}
banned = {"sklearn", "scipy", "lightgbm", "torch", "transformers", "cupy"}
roots = [Path(".")] + [Path(p) for p in ("providers", "pair_signals")]
offenders = []
for root in roots:
@@ -13490,6 +13490,305 @@ def _():
finally:
os.remove(path)
# ══════════════════════════════════════════════════════════════════════
# MODULE 36: GPU backend parity (backtest/gpu_kernels.py, 2026-09-18)
# The CUDA kernels must be bit-for-bit twins of the NumPy/pandas code;
# a fallback to the CPU must never be silent (it was, for months).
# ══════════════════════════════════════════════════════════════════════
print("\n═══ Module 36: GPU backend parity ═══")
import importlib
import importlib.util as _ilu36
from backtest import gpu_kernels as _gk36
from backtest import gpu_backtest as _gb36
_GPU36 = _gk36.BACKEND.available
if not _GPU36:
print(f" (CUDA unavailable here: {_gk36.BACKEND.describe()} — GPU-path "
f"assertions below print a notice instead of running)")
def _same36(a, b) -> bool:
"""Equality that treats NaN == NaN (floats, Timestamps, strings, None)."""
if isinstance(a, float) and isinstance(b, float) and np.isnan(a) and np.isnan(b):
return True
return a == b
@test("GPU backend: a visible CUDA device must never fall back silently")
def _():
if _ilu36.find_spec("cupy") is None:
print(" (cupy not installed: the NumPy path is the only path on this box)")
return
try:
_cupy36 = importlib.import_module("cupy") # not a literal import: the AST guard scans this file
n_dev = int(_cupy36.cuda.runtime.getDeviceCount())
except Exception as e: # noqa: BLE001
print(f" (cupy present but no usable CUDA device: {type(e).__name__})")
return
if n_dev < 1:
print(" (no CUDA device visible)")
return
if _gk36.BACKEND.mode == "cpu":
print(" (backend forced to cpu by FB2_BACKEND)")
return
assert _gk36.BACKEND.available, (
f"cupy sees {n_dev} device(s) but the backend fell back: {_gk36.BACKEND.reason}")
assert _gk36.BACKEND.describe().startswith("CuPy GPU ("), _gk36.BACKEND.describe()
assert _gk36.BACKEND.device_name and _gk36.BACKEND.cupy_version
@test("GPU backend: ewm_batch is bit-identical to _gpu_ewm_alpha (NaN head, interior NaN, flat run, short)")
def _():
if not _GPU36:
print(" (no CUDA: kernel not exercised)")
return
rng = np.random.default_rng(36)
n = 700
gold = 3300.0 + np.cumsum(rng.normal(0, 3.0, n))
nan_head = gold.copy(); nan_head[:15] = np.nan
interior = gold.copy(); interior[100:107] = np.nan; interior[400] = np.nan
flat = gold.copy(); flat[200:260] = flat[199]
short = gold[:9]
gainlike = np.where(rng.normal(size=n) > 0, np.abs(rng.normal(size=n)), 0.0)
gainlike[0] = 0.0
series, alphas, minps, refs = [], [], [], []
for x in (gold, nan_head, interior, flat, short, gainlike):
for alpha, mp in ((2.0 / 10, 0), (2.0 / 201, 0), (1.0 / 14, 14), (1.0 / 10, 10), (0.5, 3)):
series.append(x); alphas.append(alpha); minps.append(mp)
refs.append(_gb36._gpu_ewm_alpha(x, alpha, mp))
outs = _gk36.ewm_batch(series, alphas, minps)
assert len(outs) == len(refs) == 30
for i, (o, r) in enumerate(zip(outs, refs)):
assert o.dtype == np.float64 and o.shape == r.shape, i
assert np.array_equal(o, r, equal_nan=True), (
f"case {i}: {int(np.sum(~((o == r) | (np.isnan(o) & np.isnan(r)))))} bits differ")
# and the CPU twin itself still equals pandas on a clean series (live-engine tie)
assert np.array_equal(
pd.Series(gold).ewm(span=200, adjust=False).mean().to_numpy(),
_gk36.ewm_batch([gold], [2.0 / 201], [0])[0], equal_nan=True)
assert _gk36.ewm_batch([], [], []) == []
@test("GPU backend: roll_mean_var is bit-identical to pandas rolling mean/var/std (NaN prefix, mid NaN, flat window, spike)")
def _():
if not _GPU36:
print(" (no CUDA: kernel not exercised)")
return
rng = np.random.default_rng(37)
n = 900
gold = 3300.0 + np.cumsum(rng.normal(0, 3.0, n))
nan_prefix = gold.copy(); nan_prefix[:19] = np.nan
mid_nan = gold.copy(); mid_nan[300:305] = np.nan # nobs dips below min_periods, recovers
flat = gold.copy(); flat[100:120] = 3311.25; flat[120] = 3311.75 # exactly one window of equal values
spike = gold.copy(); spike[500] = 1e6 # no recompute trigger
cancel = gold.copy(); cancel[600] = 1e12 # trips the InvCondTol recompute
tiny = gold[:7] # n < window: all NaN
centred = gold - gold.mean() # sign changes exercise neg_ct
series = [gold, nan_prefix, mid_nan, flat, spike, cancel, tiny, centred, -gold]
means, vars_, stds = _gk36.roll_mean_var(series, 20, ddof=0)
for i, x in enumerate(series):
s = pd.Series(x)
pm = s.rolling(20).mean().to_numpy()
pv = s.rolling(20).var(ddof=0).to_numpy()
ps = s.rolling(20).std(ddof=0).to_numpy()
assert np.array_equal(means[i], pm, equal_nan=True), f"mean case {i}"
assert np.array_equal(vars_[i], pv, equal_nan=True), f"var case {i}"
assert np.array_equal(stds[i], ps, equal_nan=True), f"std case {i}"
assert np.isnan(means[6]).all() and np.isnan(vars_[6]).all()
# ddof=1 too (pandas' default), on the plain series
(m1,), (v1,), _ = _gk36.roll_mean_var([gold], 20, ddof=1)
assert np.array_equal(v1, pd.Series(gold).rolling(20).var(ddof=1).to_numpy(), equal_nan=True)
@test("GPU backend: precompute_indicators GPU == CPU on every IndicatorCache field; arrays are host NumPy")
def _():
df = make_xau_ohlcv(600, seed=36)
cols = df.columns.get_indexer(["Open", "High", "Low", "Close"])
df.iloc[250:262, cols] = float(df["Close"].iloc[249]) # flat stretch: zero TR / DM divisors
tech = TechnicalConfig()
with _gk36.BACKEND.cpu_only():
c = _gb36.precompute_indicators(df, tech)
_gb36._assert_host_cache(c)
if not _GPU36:
print(" (no CUDA: CPU twin only)")
return
g = _gb36.precompute_indicators(df, tech)
_gb36._assert_host_cache(g)
checked = 0
for name in vars(g):
if name in ("df", "index_pos"):
continue
a, b = getattr(g, name), getattr(c, name)
if a is None or b is None:
assert a is None and b is None, name
continue
if isinstance(a, pd.Series):
assert a.index.equals(b.index), name
a, b = a.to_numpy(), b.to_numpy()
assert type(a) is np.ndarray and a.dtype == np.float64, name
assert np.array_equal(a, b, equal_nan=True), (
f"{name}: {int(np.sum(~((a == b) | (np.isnan(a) & np.isnan(b)))))} values differ")
checked += 1
assert checked >= 24, checked
assert g.index_pos == c.index_pos
# the short-frame branch (no EMA200) is identical too
short = make_xau_ohlcv(150, seed=5)
gs = _gb36.precompute_indicators(short, tech)
with _gk36.BACKEND.cpu_only():
cs = _gb36.precompute_indicators(short, tech)
assert gs.ema_trend is None and cs.ema_trend is None
assert np.array_equal(gs.adx, cs.adx, equal_nan=True)
def _sim_fixture36():
rng = np.random.default_rng(38)
n = 1500
idx = pd.date_range("2025-01-06", periods=n, freq="15min", tz="UTC")
close = 3000 + np.cumsum(rng.normal(0, 2.0, n))
op = close + rng.normal(0, 0.5, n)
hi = np.maximum(op, close) + np.abs(rng.normal(0, 1.5, n))
lo = np.minimum(op, close) - np.abs(rng.normal(0, 1.5, n))
for j in (500,): # a NaN bar
op[j] = hi[j] = lo[j] = close[j] = np.nan
op[900] = close[899] - 25.0; lo[900] = min(lo[900], op[900]) # gap through a BUY stop
op[950] = close[949] + 25.0; hi[950] = max(hi[950], op[950]) # gap through a SELL stop / beyond a BUY target
close[n - 1] = np.nan # NaN close on a final window bar
sim = pd.DataFrame({"Open": op, "High": hi, "Low": lo, "Close": close}, index=idx)
def mk(i, buy, arm=0.0, dist=0.0, risk=6.0, tp_dist=9.0):
e = float(op[i]) if not np.isnan(op[i]) else float(close[i - 1])
sl = e - risk if buy else e + risk
tp = e + tp_dist if buy else e - tp_dist
return _gb36.Trade(pair=XAUUSD, entry_time=idx[i], entry_price=e, sl=sl, tp1=tp,
tp2=tp + 3, direction="BUY" if buy else "SELL", pip_value=0.1,
risk_pips=60, reward_pips=90, rr_planned=1.5, quality="A",
final_score=40, final_confidence=60, market_regime="trending_up",
trail_arm_r=arm, trail_dist_r=dist)
trades = [mk(int(i), bool(rng.integers(0, 2)), *((0.7, 0.85) if k % 5 == 0 else (0.0, 0.0)))
for k, i in enumerate(rng.integers(1, n - 1, 300))]
trades += [mk(n - 1, True), mk(n - 1, False)] # window of one NaN-close bar
trades += [mk(n - 3, True, risk=500.0, tp_dist=500.0),
mk(n - 3, False, risk=500.0, tp_dist=500.0)] # TIMEOUT onto the NaN close -> NO_DATA
trades += [mk(n - 400, True, risk=500.0, tp_dist=500.0),
mk(n - 400, False, risk=500.0, tp_dist=500.0)] # a clean TIMEOUT at max_bars
trades += [mk(500, True), mk(500, False)] # NaN bar first (NO_DATA)
trades += [mk(499, True), mk(499, False)] # NaN bar second
trades += [mk(899, True), mk(949, False), mk(949, True)] # gap fills
trades += [mk(200, True, risk=0.0), mk(200, False, risk=0.0)] # zero risk
return sim, trades
@test("GPU backend: simulate_trades GPU == CPU (trailing and static; NaN windows, gaps, zero risk, per-trade trail)")
def _():
if not _GPU36:
print(" (no CUDA: kernel not exercised)")
return
sim, trades = _sim_fixture36()
fields = ("exit_reason", "exit_price", "exit_time", "bars_held", "r_multiple", "mfe_r", "mae_r")
for arm in (0.6, 0.0):
a = copy.deepcopy(trades)
b = copy.deepcopy(trades)
_gb36.simulate_trades(a, sim, max_bars=300, spread=0.65, trail_arm_r=arm, trail_dist_r=0.75)
with _gk36.BACKEND.cpu_only():
_gb36.simulate_trades(b, sim, max_bars=300, spread=0.65, trail_arm_r=arm, trail_dist_r=0.75)
for i, (x, y) in enumerate(zip(a, b)):
for f in fields:
assert _same36(getattr(x, f), getattr(y, f)), (
f"arm={arm} trade {i} {f}: gpu={getattr(x, f)!r} cpu={getattr(y, f)!r}")
reasons = {t.exit_reason for t in a}
assert "NO_DATA" in reasons and "TIMEOUT" in reasons, reasons
assert ("TRAIL" in reasons) == (arm > 0)
assert ("TP1" in reasons) == (arm == 0)
# the NaN-close TIMEOUT never leaks a NaN R into the book
assert all(np.isfinite(t.r_multiple) for t in a)
# empty and no-data-after-entry cases return without touching the kernel
_gb36.simulate_trades([], sim, max_bars=300)
@test("GPU backend: --backend parses, FB2_BACKEND is honoured, cpu_only() routes to the NumPy twins, gpu fails loudly")
def _():
assert _gb36._parse_args(["--pair", "XAUUSD", "--backend", "cpu"]).backend == "cpu"
assert _gb36._parse_args(["--pair", "XAUUSD"]).backend is None
try:
_gb36._parse_args(["--pair", "XAUUSD", "--backend", "tpu"])
assert False, "invalid backend must be rejected"
except SystemExit:
pass
with patch.dict(os.environ, {_gk36.ENV_BACKEND: "cpu"}):
b = _gk36.Backend()
assert b.mode == "cpu" and b.available is False
assert b.describe().startswith("NumPy CPU fallback (forced")
with patch.dict(os.environ, {_gk36.ENV_BACKEND: "nonsense"}):
assert _gk36.Backend().mode == "auto"
# cpu_only(): the GPU twin must not be called
with _gk36.BACKEND.cpu_only():
assert _gk36.BACKEND.available is False
with patch.object(_gb36, "_precompute_indicators_gpu",
side_effect=AssertionError("GPU twin called under cpu_only")):
_gb36.precompute_indicators(make_xau_ohlcv(120, seed=1), TechnicalConfig())
with patch.object(_gb36, "_resolve_trades_gpu",
side_effect=AssertionError("GPU resolver called under cpu_only")):
sim, trades = _sim_fixture36()
_gb36.simulate_trades(trades[:5], sim, max_bars=50, trail_arm_r=0.6)
assert _gk36.BACKEND.describe().startswith(("CuPy GPU (", "NumPy CPU fallback ("))
# --backend gpu without CUDA raises instead of falling back
b3 = _gk36.Backend()
b3._probed, b3._ok, b3.reason = True, False, "synthetic probe failure"
try:
b3.set_mode("gpu")
assert False, "must raise"
except RuntimeError as e:
assert "synthetic probe failure" in str(e)
assert b3.describe() == "NumPy CPU fallback (synthetic probe failure)"
try:
b3.set_mode("tpu")
assert False
except ValueError:
pass
# release_device_memory is a no-op without CUDA
b3.release_device_memory()
@test("GPU backend: gpu_kernels imports none of the live writers; cupy never enters the live roots or the backtester")
def _():
banned = {"signal_log", "news_engine", "providers", "telegram_bot", "news_archive",
"agent_panel", "violent_watch", "gpu_backtest", "engine", "signal_aggregator",
"replay_record", "sklearn", "scipy", "lightgbm", "torch"}
kpath = Path("backtest/gpu_kernels.py")
seen = set()
for node in ast.walk(ast.parse(kpath.read_text(encoding="utf-8"), str(kpath))):
if isinstance(node, ast.Import):
seen.update(a.name.split(".")[0] for a in node.names)
elif isinstance(node, ast.ImportFrom):
seen.add((node.module or "").split(".")[0])
assert not (seen & banned), seen & banned
assert "numpy" in seen and "cupy" in seen # cupy only inside the probe / kernels
ksrc = kpath.read_text(encoding="utf-8")
assert 'KERNEL_OPTIONS = ("--fmad=false",)' in ksrc # no FMA contraction, ever
assert "options=KERNEL_OPTIONS" in ksrc
# the backtester never imports cupy itself; it reads backend state through gpu_kernels
bsrc = Path("backtest/gpu_backtest.py").read_text(encoding="utf-8")
assert "import cupy" not in bsrc and "_GPU_AVAILABLE" not in bsrc
assert "from backtest import gpu_kernels" in bsrc
assert "BACKEND.release_device_memory()" in bsrc # before the fork
assert bsrc.index("BACKEND.release_device_memory()") < bsrc.index('mp.get_context("fork")')
# live roots stay free of cupy (Module 32's guard bans it too)
offenders = []
for root in (Path("."), Path("providers"), Path("pair_signals")):
for path in sorted(root.glob("*.py")):
if "venv" in path.parts:
continue
for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"), str(path))):
names = ([a.name for a in node.names] if isinstance(node, ast.Import)
else [node.module or ""] if isinstance(node, ast.ImportFrom) else [])
offenders += [f"{path}:{node.lineno}" for nm in names if nm.split(".")[0] == "cupy"]
assert not offenders, offenders
assert "cupy" not in open("requirements.txt").read()
# ══════════════════════════════════════════════════════════════════════
# SUMMARY
# ══════════════════════════════════════════════════════════════════════