backtest: --record/--record-bars Parquet recorder + 2-year M5/M15 replay with indicator weights and calendar

Adds an opt-in recorder to the backtester so a replay keeps the evidence each
trade setup was decided on: every indicator's vote, value, confidence, base
weight, regime multiplier, effective weight and contribution on the primary and
both confirmation timeframes, penalties, the setup and outcome, and the Forex
Factory calendar events around the trade (4 h before entry to exit, all-day rows
on the New-York date). --record-bars adds one float32 row per analyzed bar with
the same votes plus 1 h/4 h/24 h forward moves in ATRs. Output is Parquet in a
folder per run with a manifest carrying the engine/gate fingerprints, the regime
weight table in force and the book MD5.

Nothing in the signal core changes: the trade book is byte-identical with
recording on or off (control-vs-recorded MD5 equal on both 2-year runs, M5
781 / +146.07R / PF 1.52, M15 793 / +117.62R / PF 1.41 at $0.65). Module 33
adds 12 tests (498 total): snapshot reproduces the engine's composite, absence
is no vote, calendar join edges/phases/holiday, write/load round-trip,
_replay_span parity with recording on, CLI flags, import and source guards.

Also: backtest/replay_scorecard.py (first-look scorecards, calendar buckets,
in-book and per-bar what-ifs, --check invariants), scripts/ff_calendar_scraper.py
restored from git history (the only way to regenerate the gitignored calendar
file), report in backtest/research/replay_recording_2026-09-09.md.

Claude-Session: https://claude.ai/code/session_01Nc9Ninzyi18QkWezrxeBvB
This commit is contained in:
Harwinder Sekhon
2026-09-09 16:41:49 -04:00
parent d05348d1e4
commit daa2369ccb
9 changed files with 2965 additions and 4 deletions
+75 -3
View File
@@ -63,6 +63,7 @@ from providers import BaseDataProvider, make_data_provider # noqa: E402
from engine import IndicatorSignal, TechnicalAnalysis, TechnicalEngine # noqa: E402
from signal_aggregator import SignalAggregator, SignalReport, TradeSetup # noqa: E402
import position_sizing # noqa: E402
from backtest import replay_record # noqa: E402
# ─────────────────────────────────────────────────────────────────────
# GPU shim — CuPy if available AND CUDA runtime works, else NumPy.
@@ -1079,6 +1080,11 @@ class Trade:
# untouched.
features: Dict[str, float] = field(default_factory=dict)
stake_multiplier: float = 1.0
# RESEARCH (--record only): plain-data decision snapshot built by
# replay_record.snapshot_report — indicator weights/values on every
# timeframe, penalties, setup. Empty on normal runs, so pickling a
# Trade back from a worker costs nothing and the export is untouched.
snapshot: Dict = field(default_factory=dict)
# Filled on exit:
exit_time: Optional[pd.Timestamp] = None
@@ -1465,6 +1471,13 @@ class BacktestConfig:
# columns would break comparison against the pinned 63-column books,
# and the feature build costs time per candidate.
export_features: bool = False
# RESEARCH (--record DIR): write manifest.json + trades/indicators/
# events parquet describing every accepted trade to this directory.
# Read-only w.r.t. the signal path — the book is unchanged.
record_dir: Optional[str] = None
# With record_dir set: also store one compact row per ANALYZED
# primary bar (bars.parquet), not just the accepted trades.
record_bars: bool = False
def _is_xau_blocked_by_calendar(ts: pd.Timestamp, cfg: PlatformConfig) -> Optional[str]:
@@ -1518,6 +1531,9 @@ def _replay_span(span: Tuple[int, int]):
skipped: Dict[str, int] = {}
candidates: List[Tuple[int, Trade]] = []
is_xau = PAIR_INFO[bt_cfg.pair]["display"] == "XAU/USD"
record = bool(bt_cfg.record_dir)
record_bars = record and bt_cfg.record_bars
bar_buf = replay_record.new_bar_buffer() if record_bars else None
for pos in range(span[0], span[1]):
ts = primary_df.index[pos]
@@ -1575,6 +1591,12 @@ def _replay_span(span: Tuple[int, int]):
if platform_cfg.backtest.regime_filter:
aggregator._apply_regime_filter_live(report, slice_dfs)
# RESEARCH (--record-bars): every ANALYZED bar, setup or not.
if record_bars:
replay_record.add_bar_row(
bar_buf, pos, ts, report,
bt_cfg.primary_tf, bt_cfg.confirmation_tfs)
if report.trade_setup is None:
continue
if report.signal_quality not in bt_cfg.quality_filter:
@@ -1632,6 +1654,13 @@ def _replay_span(span: Tuple[int, int]):
list(bt_cfg.confirmation_tfs),
)
# RESEARCH (--record): plain-data decision snapshot. Read-only —
# it never touches the report or the setup.
snap = (replay_record.snapshot_report(
report, ts_setup, decision_time,
bt_cfg.primary_tf, list(bt_cfg.confirmation_tfs))
if record else {})
candidates.append((pos, Trade(
pair=bt_cfg.pair,
entry_time=entry_time,
@@ -1658,6 +1687,7 @@ def _replay_span(span: Tuple[int, int]):
indicators_neutral=neutral,
indicators_total=total,
features=feats,
snapshot=snap,
stake_multiplier=float(ts_setup.stake_multiplier
if ts_setup.stake_multiplier is not None
else 1.0),
@@ -1667,7 +1697,8 @@ def _replay_span(span: Tuple[int, int]):
if tag == "XAU_PB" else 0.0),
)))
return skipped, candidates
return skipped, candidates, (replay_record.finish_bar_rows(bar_buf)
if record_bars else None)
def _reanchor_entries_to_raw(
@@ -1791,6 +1822,9 @@ def run_backtest(
fetch_start = bt_cfg.start - timedelta(days=pad_days)
dfs_full: Dict[Timeframe, pd.DataFrame] = {}
# Which parquet each TF came from (recorded in --record's manifest so
# a study can prove two runs read the same bars).
cache_names: Dict[str, str] = {}
for tf in timeframes:
# Slow TFs need a longer pad: 60 days of D1 is only ~44 bars — no
# EMA200, truncated swing/divergence windows — while live always has
@@ -1800,6 +1834,8 @@ def run_backtest(
bars_per_day = BaseDataProvider.BARS_PER_DAY[tf]
min_pad_days = int(np.ceil(210 / bars_per_day * 7 / 5)) + 5
tf_start = bt_cfg.start - timedelta(days=max(pad_days, min_pad_days))
cache_names[tf.value] = _cache_path(
bt_cfg.pair, tf, tf_start, bt_cfg.end, provider.name).name
df = fetch_history(
provider, bt_cfg.pair, tf,
start=tf_start, end=bt_cfg.end,
@@ -1874,10 +1910,13 @@ def run_backtest(
skipped_reasons: Dict[str, int] = {}
candidates: List[Tuple[int, Trade]] = []
for skips, cands in results:
bar_chunks: List = []
for skips, cands, bars in results:
for reason, count in skips.items():
skipped_reasons[reason] = skipped_reasons.get(reason, 0) + count
candidates.extend(cands)
if bars is not None:
bar_chunks.append(bars)
candidates.sort(key=lambda pc: pc[0])
# ── Phase 2: entry cooldown — the only sequential rule.
@@ -1885,6 +1924,7 @@ def run_backtest(
trades: List[Trade] = [t for _, t in accepted]
signal_ts_list: List[pd.Timestamp] = [primary_df.index[p] for p, _ in accepted]
replay_seconds = time.perf_counter() - t_loop
logger.info(
f"Replay complete: {len(trades)} entries in "
f"{time.perf_counter() - t_loop:.1f}s "
@@ -1920,6 +1960,19 @@ def run_backtest(
f"(GPU={'yes' if _GPU_AVAILABLE else 'no'})"
)
if bt_cfg.record_dir:
replay_record.write_run(
bt_cfg.record_dir, trades=trades, book_df=trades_to_dataframe(trades),
bt_cfg=bt_cfg, platform_cfg=platform_cfg, aggregator=aggregator,
run_info=dict(first_pos=first_eligible_pos, last_pos=last_pos,
n_primary_bars=len(primary_df), n_bars_in_window=n_bars,
skipped=skipped_reasons, n_candidates=len(candidates),
workers=workers,
backend="CuPy GPU" if _GPU_AVAILABLE else "NumPy CPU fallback",
cache_files=cache_names, replay_seconds=replay_seconds),
sim_df=sim_df, bar_chunks=bar_chunks or None)
logger.info(f"--record: wrote run to {bt_cfg.record_dir}")
return trades, dfs_full
@@ -2313,6 +2366,13 @@ def _parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
"(+ stake_multiplier, r_sized) to --export-csv; "
"research flag, default off so pinned 63-column "
"books stay comparable")
p.add_argument("--record", metavar="DIR", default=None,
help="RESEARCH: write manifest.json + trades/indicators/"
"events parquet for every accepted trade to DIR; "
"the book and CSV are unchanged")
p.add_argument("--record-bars", action="store_true",
help="with --record: also write one row per analyzed "
"primary bar (bars.parquet, ~140k rows on 2-yr M5)")
p.add_argument("--detail", action="store_true",
help="Print full per-trade reasoning (indicators, signals, agreement, why entered)")
p.add_argument("--workers", type=int, default=0,
@@ -2320,7 +2380,10 @@ def _parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
p.add_argument("--reasoning-samples", type=int, default=3,
help="If --detail not set, print this many sample trades' full reasoning")
p.add_argument("--log-level", default="INFO")
return p.parse_args(argv)
args = p.parse_args(argv)
if args.record_bars and not args.record:
p.error("--record-bars requires --record")
return args
def _resolve_pair(s: str) -> str:
@@ -2466,7 +2529,16 @@ def main(argv: Optional[List[str]] = None) -> int:
workers=args.workers,
fixed_rr_tp=args.fixed_rr_tp or 0.0,
export_features=args.export_features,
record_dir=args.record,
record_bars=args.record_bars,
)
if args.record:
logger.info(
f"--record ON: decision snapshots → {args.record} "
f"(trades/indicators/events"
f"{'/bars' if args.record_bars else ''} parquet + manifest.json; "
f"the trade book is unchanged)"
)
if args.export_features:
logger.info(
f"--export-features ON: appending stake_multiplier, r_sized and "