Files
ForexBot2/scripts/baseline_common.py
T
Harwinder Sekhon 1c264d5315 baseline: byte-for-byte backtest reference at da61cff + compare tooling
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
2026-08-07 16:34:14 -04:00

308 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Shared machinery for the backtest baseline capture/compare pair.
`capture_baseline.py` and `compare_baseline.py` both import everything
here so the two sides produce their outputs from literally the same code:
the run-spec table, the subprocess runner, the equity/metrics derivations,
and the cache/stderr sanity checks.
Reproducibility invariants encoded here (see baseline/<commit>/README.md):
* --start/--end always pinned — `--days` bakes wall-clock time-of-day
into the first replay bar (gpu_backtest.py resolves `end` with
datetime.now when --end is absent).
* --warmup-bars pinned at 250 — it feeds pad_days and therefore the
parquet cache FILENAME.
* Subprocess env drops CUDA_PATH (pins the NumPy CPU backend; setting
it would silently flip _simulate_trailing onto the CuPy path) and
sets PYTHONHASHSEED=0 (removes str-hash set-ordering as a variable).
* Data must come from backtest/cache/ — a cache miss would hit the
Oanda API (and the stale .env token 401s), and freshly fetched bars
are not the baseline's bars. Any "Fetching" line in stderr fails the
run.
* stdout (summary.txt) is byte-comparable; stderr (run.log) carries
%(asctime)s timestamps and elapsed times and is provenance-only.
"""
from __future__ import annotations
import hashlib
import json
import math
import os
import subprocess
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Dict, List, Tuple
import pandas as pd
ROOT = Path(__file__).resolve().parents[1]
VENV_PYTHON = ROOT / "venv" / "bin" / "python"
BACKTESTER_REL = "backtest/gpu_backtest.py"
CACHE_DIR = ROOT / "backtest" / "cache"
BASELINE_ROOT = ROOT / "baseline"
WINDOW_START = "2025-08-04"
WINDOW_END = "2026-08-04"
#: Files that must match byte-for-byte on compare. run.log is deliberately
#: absent (timestamped stderr, never comparable).
COMPARED_FILES = ("trades.csv", "summary.txt", "equity.csv", "metrics.json")
#: Canonical factory confirm map (pair_signals/xauusd.py) — pinned on the
#: command line so each invocation is self-contained.
CONFIRMS = {
"5min": "15min,1h",
"15min": "5min,1h",
"1h": "15min,4h",
"4h": "1h,1d",
}
#: Mirrors BaseDataProvider.BARS_PER_DAY for the TFs the baseline touches.
#: Kept local so the scripts never import repo modules (importing app_config
#: pulls the .env into os.environ). Drift is self-detecting: if the pad
#: math ever changes upstream, the expected cache file goes missing and the
#: pre-flight aborts before any network fetch.
_BARS_PER_DAY = {"5min": 288, "15min": 96, "1h": 24, "4h": 6, "1d": 1}
#: fetch_history logs an ERROR for parquets older than the raw-cache format
#: change (2026-07-08) — those contain CLEANED bars and are invalid input.
RAW_CACHE_EPOCH = 1783468800.0
# ─────────────────────────────────────────────────────────────────────
# Run specs
# ─────────────────────────────────────────────────────────────────────
def run_specs() -> Dict[str, dict]:
"""The 8-run baseline matrix: 4 primary TFs × spreads {0.30, 0.65}."""
specs: Dict[str, dict] = {}
for key, tf in (("m5", "5min"), ("m15", "15min"), ("h1", "1h"), ("h4", "4h")):
for spread in ("0.30", "0.65"):
run_key = f"{key}_s{spread.replace('.', '')}" # 0.30 → s030
specs[run_key] = {
"primary_tf": tf,
"confirm_tfs": CONFIRMS[tf],
"spread": spread,
"argv": build_argv(tf, spread),
}
return specs
def build_argv(primary_tf: str, spread: str) -> List[str]:
"""Backtester argv with every reproducibility-relevant knob pinned.
--export-csv is appended at run time (path depends on the output dir).
"""
return [
BACKTESTER_REL,
"--pair", "XAUUSD",
"--primary-tf", primary_tf,
"--confirm-tfs", CONFIRMS[primary_tf],
"--start", WINDOW_START,
"--end", WINDOW_END,
"--spread", spread,
"--quality", "A+,A,B,C",
"--warmup-bars", "250",
"--cooldown-bars", "5",
"--max-hold-bars", "300",
"--workers", "4",
"--reasoning-samples", "3",
"--detail",
]
def expected_cache_files(primary_tf: str) -> List[str]:
"""Parquet filenames a run will read (mirrors gpu_backtest pad math).
pad_days = max(60, warmup_bars // 4) = 62 at the pinned 250;
per-TF floor min_pad_days = ceil(210 / bars_per_day * 7/5) + 5.
"""
start = datetime.strptime(WINDOW_START, "%Y-%m-%d")
end = datetime.strptime(WINDOW_END, "%Y-%m-%d")
pad_days = max(60, 250 // 4)
names = []
for tf in [primary_tf] + CONFIRMS[primary_tf].split(","):
min_pad = math.ceil(210 / _BARS_PER_DAY[tf] * 7 / 5) + 5
tf_start = start - timedelta(days=max(pad_days, min_pad))
names.append(
f"oanda_XAU_USD_{tf}_{tf_start:%Y%m%d}_{end:%Y%m%d}.parquet")
return names
def all_expected_cache_files(specs: Dict[str, dict]) -> List[str]:
seen: List[str] = []
for spec in specs.values():
for name in expected_cache_files(spec["primary_tf"]):
if name not in seen:
seen.append(name)
return seen
# ─────────────────────────────────────────────────────────────────────
# Subprocess runner + stderr sanity
# ─────────────────────────────────────────────────────────────────────
def run_backtest(argv: List[str], run_dir: Path) -> None:
"""Run one backtest: stdout → summary.txt, stderr → run.log.
Raises RuntimeError (with the log path) on a nonzero exit.
"""
run_dir.mkdir(parents=True, exist_ok=True)
env = {k: v for k, v in os.environ.items() if k != "CUDA_PATH"}
env["PYTHONHASHSEED"] = "0"
cmd = ([str(VENV_PYTHON)] + argv
+ ["--export-csv", str(run_dir / "trades.csv")])
with open(run_dir / "summary.txt", "wb") as out, \
open(run_dir / "run.log", "wb") as err:
rc = subprocess.run(cmd, cwd=ROOT, env=env,
stdout=out, stderr=err).returncode
if rc != 0:
raise RuntimeError(
f"backtest exited {rc} — see {run_dir / 'run.log'}")
def check_run_log(run_dir: Path, primary_tf: str) -> Tuple[List[str], str]:
"""Sanity-check a run's stderr. Returns (problems, backend_string).
Hard requirements: every TF loaded from the parquet cache (zero
"Fetching" lines — a live fetch means the input data is not the
baseline's data) and a parseable Backend line.
"""
text = (run_dir / "run.log").read_text(errors="replace")
problems: List[str] = []
if "Fetching " in text:
problems.append("live fetch attempted (cache miss) — data is not "
"the baseline's data")
for tf in [primary_tf] + CONFIRMS[primary_tf].split(","):
if f"Loading cached XAU/USD {tf}:" not in text:
problems.append(f"no cache-hit line for TF {tf}")
if "predates the raw-cache format" in text:
problems.append("pre-2026-07-08 cache file in use (cleaned bars)")
backend = ""
for line in text.splitlines():
if "Backend: " in line:
backend = line.split("Backend: ", 1)[1].split(" | ", 1)[0].strip()
break
if not backend:
problems.append("no Backend line found in run.log")
return problems, backend
# ─────────────────────────────────────────────────────────────────────
# Derived outputs (the backtester exports no equity curve — derive it
# from the trade book instead of modifying gpu_backtest.py)
# ─────────────────────────────────────────────────────────────────────
def _resolved_trades(trades_csv: Path) -> pd.DataFrame:
"""Resolved trades in book order (matches compute_metrics' filter:
unresolved '' / NO_DATA rows are exported to CSV but never scored)."""
df = pd.read_csv(trades_csv, dtype=str, keep_default_na=False)
df["r_multiple"] = pd.to_numeric(df["r_multiple"], errors="coerce")
mask = (df["exit_reason"] != "") & (df["exit_reason"] != "NO_DATA") \
& df["r_multiple"].notna()
return df[mask].reset_index(drop=True)
def derive_equity(trades_csv: Path, out_path: Path) -> None:
"""Cumulative-R equity curve over resolved trades in book order."""
df = _resolved_trades(trades_csv)
eq = pd.DataFrame({
"trade_n": range(1, len(df) + 1),
"entry_time": df["entry_time"],
"exit_time": df["exit_time"],
"quality": df["quality"],
"strategy": df["strategy"],
"r_multiple": df["r_multiple"],
})
eq["cum_r"] = eq["r_multiple"].cumsum()
eq["high_water"] = eq["cum_r"].cummax().clip(lower=0.0)
eq["drawdown_r"] = eq["cum_r"] - eq["high_water"]
eq.to_csv(out_path, index=False, float_format="%.6f")
def derive_metrics(trades_csv: Path, out_path: Path) -> dict:
"""Headline + grouped stats as sorted, fixed-precision JSON."""
df = _resolved_trades(trades_csv)
metrics = {"overall": _stats(df["r_multiple"]),
"by_quality": {}, "by_strategy": {}}
for col, bucket in (("quality", "by_quality"), ("strategy", "by_strategy")):
for value in sorted(df[col].unique()):
metrics[bucket][value] = _stats(df.loc[df[col] == value,
"r_multiple"])
out_path.write_text(json.dumps(metrics, indent=2, sort_keys=True) + "\n")
return metrics
def _stats(r: pd.Series) -> dict:
n = len(r)
wins = int((r > 0).sum())
gross_win = float(r[r > 0].sum())
gross_loss = float(-r[r < 0].sum())
cum = r.cumsum()
dd = cum - cum.cummax().clip(lower=0.0)
return {
"trades": n,
"total_r": round(float(r.sum()), 6),
"win_rate_pct": round(wins / n * 100, 6) if n else 0.0,
"profit_factor": (round(gross_win / gross_loss, 6)
if gross_loss > 0 else None),
"max_dd_r": round(float(dd.min()), 6) if n else 0.0,
}
# ─────────────────────────────────────────────────────────────────────
# Hashing / manifest helpers
# ─────────────────────────────────────────────────────────────────────
def sha256_file(path: Path) -> str:
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def config_inputs() -> dict:
"""Non-secret config.json fields that alter backtest results (the pair
factory overlays `pairs`, and `data_providers.active` keys the cache)."""
cfg_path = ROOT / "config.json"
if not cfg_path.exists():
return {"data_providers.active": None, "pairs": None}
cfg = json.loads(cfg_path.read_text())
return {
"data_providers.active": cfg.get("data_providers", {}).get("active"),
"pairs": cfg.get("pairs"),
}
def git_info() -> dict:
def _git(*args: str) -> str:
return subprocess.run(["git", *args], cwd=ROOT, capture_output=True,
text=True).stdout.strip()
return {
"commit": _git("rev-parse", "HEAD"),
"commit_short": _git("rev-parse", "--short", "HEAD"),
# -uno: untracked files (e.g. these scripts pre-commit) can't
# change the backtester; only tracked modifications count.
"dirty": bool(_git("status", "--porcelain", "-uno")),
}
def tool_versions() -> dict:
import numpy
versions = {
"python": ".".join(map(str, sys.version_info[:3])),
"numpy": numpy.__version__,
"pandas": pd.__version__,
}
try:
from importlib.metadata import version
versions["cupy"] = version("cupy")
except Exception:
versions["cupy"] = None
return versions
def utc_now_iso() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")