1c264d5315
Pin reference outputs of backtest/gpu_backtest.py (XAU/USD, window 2025-08-04..2026-08-04, 4 primary TFs x spread 0.30/0.65 = 8 runs) under baseline/da61cff/: trade books, stdout summaries, derived equity curves and metrics, plus a manifest with exact argv, input-parquet and output hashes, backend, and tool versions. scripts/capture_baseline.py captures (cache-warm only, aborts rather than fetch live); scripts/compare_baseline.py re-runs the identical commands and byte-diffs every output, PASS/FAIL per file. Verified 32/32 files PASS on full re-run at this commit; m5_s065 reproduces the pinned HEAD reference exactly (555 trades, +98.64R, PF 1.483). No signal, execution, or scanner logic touched; 359 tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J3mZCQRHnJKt7qXQFG3RhB
167 lines
6.5 KiB
Python
167 lines
6.5 KiB
Python
#!/usr/bin/env python
|
|
"""Re-run the baseline backtests and byte-diff every output against baseline/.
|
|
|
|
For each captured run, replays the exact argv recorded in the baseline's
|
|
manifest.json (same env pinning: no CUDA_PATH, PYTHONHASHSEED=0), rebuilds
|
|
the derived files, and compares trades.csv / summary.txt / equity.csv /
|
|
metrics.json byte-for-byte (sha256), printing PASS/FAIL per file.
|
|
run.log (stderr) is provenance-only and never compared.
|
|
|
|
All-PASS at the capture commit proves determinism; a FAIL after a code
|
|
change means the change altered backtest behavior — which is exactly what
|
|
this script exists to detect. Exit code 0 only if every file PASSes.
|
|
|
|
Pre-flight aborts (before any run) if an input cache parquet is missing or
|
|
its hash changed: with different input data, diffs are not attributable to
|
|
code. Live fetches are treated as failures for the same reason.
|
|
|
|
Usage:
|
|
venv/bin/python scripts/compare_baseline.py
|
|
[--baseline baseline/<commit>] [--runs m5_s030,...] [--keep]
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import shutil
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
import baseline_common as bc
|
|
|
|
|
|
def newest_baseline() -> Path | None:
|
|
if not bc.BASELINE_ROOT.is_dir():
|
|
return None
|
|
dirs = [d for d in bc.BASELINE_ROOT.iterdir()
|
|
if (d / "manifest.json").exists()]
|
|
return max(dirs, key=lambda d: (d / "manifest.json").stat().st_mtime,
|
|
default=None)
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
ap.add_argument("--baseline", default=None,
|
|
help="baseline dir (default: newest under baseline/)")
|
|
ap.add_argument("--runs", default="",
|
|
help="comma-separated run keys to compare (default: all)")
|
|
ap.add_argument("--keep", action="store_true",
|
|
help="keep re-run outputs even on all-PASS")
|
|
args = ap.parse_args()
|
|
|
|
base_dir = Path(args.baseline) if args.baseline else newest_baseline()
|
|
if not base_dir or not (base_dir / "manifest.json").exists():
|
|
print("ABORT: no baseline found — run scripts/capture_baseline.py "
|
|
"first (or pass --baseline).")
|
|
return 2
|
|
manifest = json.loads((base_dir / "manifest.json").read_text())
|
|
|
|
runs = manifest["runs"]
|
|
if args.runs:
|
|
wanted = [k.strip() for k in args.runs.split(",") if k.strip()]
|
|
unknown = [k for k in wanted if k not in runs]
|
|
if unknown:
|
|
print(f"ABORT: unknown run key(s) {unknown}; "
|
|
f"baseline has: {', '.join(runs)}")
|
|
return 2
|
|
runs = {k: runs[k] for k in wanted}
|
|
|
|
git = bc.git_info()
|
|
print(f"Baseline: {base_dir} (captured at {manifest['commit_short']}"
|
|
f"{', DIRTY' if manifest.get('dirty') else ''})")
|
|
print(f"Current HEAD: {git['commit_short']}"
|
|
f"{' (dirty)' if git['dirty'] else ''}"
|
|
+ (" — same commit, expecting all-PASS"
|
|
if git["commit"] == manifest["commit"]
|
|
else " — differs from baseline; FAILs would mean changed "
|
|
"backtest behavior"))
|
|
|
|
# ── Pre-flight: identical input data, or nothing is attributable ──
|
|
needed = sorted({f for r in runs.values() for f in r["cache_files"]})
|
|
data_problems = []
|
|
for name in needed:
|
|
path = bc.CACHE_DIR / name
|
|
if not path.exists():
|
|
data_problems.append(f"missing cache parquet: {name}")
|
|
elif bc.sha256_file(path) != manifest["cache_files"].get(name):
|
|
data_problems.append(f"cache parquet CHANGED since capture: "
|
|
f"{name}")
|
|
if data_problems:
|
|
print("ABORT: input data drift — diffs would not be attributable "
|
|
"to code:")
|
|
for p in data_problems:
|
|
print(f" - {p}")
|
|
return 2
|
|
|
|
cfg_now = bc.config_inputs()
|
|
if cfg_now != manifest.get("config_inputs"):
|
|
print("WARNING: non-secret config.json inputs differ from capture "
|
|
"time (data_providers.active / pairs overlay) — these alter "
|
|
"results:")
|
|
print(f" baseline: {manifest.get('config_inputs')}")
|
|
print(f" now: {cfg_now}")
|
|
|
|
work_dir = Path(tempfile.mkdtemp(prefix="baseline_compare_"))
|
|
print(f"Re-running {len(runs)} run(s) into {work_dir}\n")
|
|
|
|
n_pass = n_fail = 0
|
|
failed_runs = []
|
|
for i, (key, spec) in enumerate(runs.items(), 1):
|
|
run_dir = work_dir / key
|
|
print(f"[{i}/{len(runs)}] {key}")
|
|
try:
|
|
bc.run_backtest(spec["argv"], run_dir)
|
|
problems, backend = bc.check_run_log(run_dir, spec["primary_tf"])
|
|
if backend and backend != manifest.get("backend"):
|
|
problems.append(f"backend {backend!r} != baseline "
|
|
f"{manifest.get('backend')!r}")
|
|
if problems:
|
|
raise RuntimeError("; ".join(problems))
|
|
bc.derive_equity(run_dir / "trades.csv", run_dir / "equity.csv")
|
|
bc.derive_metrics(run_dir / "trades.csv",
|
|
run_dir / "metrics.json")
|
|
except Exception as exc:
|
|
print(f" RUN FAILED: {exc}")
|
|
for fname in bc.COMPARED_FILES:
|
|
print(f" {fname:<13} FAIL (run failed)")
|
|
n_fail += 1
|
|
failed_runs.append(key)
|
|
continue
|
|
|
|
run_ok = True
|
|
for fname in bc.COMPARED_FILES:
|
|
new = run_dir / fname
|
|
ref = base_dir / key / fname
|
|
same = (new.exists() and ref.exists()
|
|
and bc.sha256_file(new) == spec["files"][fname])
|
|
print(f" {fname:<13} {'PASS' if same else 'FAIL'}")
|
|
if same:
|
|
n_pass += 1
|
|
else:
|
|
n_fail += 1
|
|
run_ok = False
|
|
if new.exists() and ref.exists():
|
|
print(f" baseline: {ref} ({ref.stat().st_size} B)")
|
|
print(f" re-run: {new} ({new.stat().st_size} B)")
|
|
if not run_ok:
|
|
failed_runs.append(key)
|
|
|
|
total = n_pass + n_fail
|
|
print(f"\nRESULT: {n_pass}/{total} files PASS across {len(runs)} run(s) "
|
|
f"vs baseline {manifest['commit_short']}")
|
|
if n_fail:
|
|
print(f"FAILED runs: {', '.join(dict.fromkeys(failed_runs))}")
|
|
print(f"Re-run outputs kept for inspection: {work_dir}")
|
|
return 1
|
|
if args.keep:
|
|
print(f"Re-run outputs kept (--keep): {work_dir}")
|
|
else:
|
|
shutil.rmtree(work_dir, ignore_errors=True)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|