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:
@@ -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 "
|
||||
|
||||
Reference in New Issue
Block a user