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:
+580
@@ -11281,6 +11281,586 @@ def _():
|
||||
assert name not in req, f"{name} must not enter requirements.txt"
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
# MODULE 33: backtest replay recording (--record, research-only)
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
print("\n═══ Module 33: backtest replay recording ═══")
|
||||
|
||||
import contextlib as _ctx33
|
||||
import dataclasses as _dc33
|
||||
import hashlib as _hashlib33
|
||||
import pickle as _pickle33
|
||||
import types as _types33
|
||||
|
||||
from backtest import gpu_backtest as _gb33
|
||||
from backtest import replay_record as _rr
|
||||
|
||||
_M33_CACHE = {}
|
||||
|
||||
|
||||
def _m33_report():
|
||||
"""One REAL M5 analyze_from_dataframes run + a hand-built setup.
|
||||
|
||||
Cached — the engine replay is the slow part and two tests need the
|
||||
same report. Returns (cfg, aggregator, report, setup)."""
|
||||
if "report" not in _M33_CACHE:
|
||||
from pair_signals.xauusd import make_config as _mk33
|
||||
cfg = _mk33(Timeframe.M5, [Timeframe.M15, Timeframe.H1])
|
||||
dfs = {
|
||||
Timeframe.M5: make_xau_bars(n=400, freq="5min",
|
||||
end="2026-07-08 16:55"),
|
||||
Timeframe.M15: make_xau_bars(n=300, freq="15min",
|
||||
end="2026-07-08 16:45", seed=8),
|
||||
Timeframe.H1: make_xau_bars(n=300, freq="60min",
|
||||
end="2026-07-08 16:00", seed=9),
|
||||
}
|
||||
agg = SignalAggregator(cfg)
|
||||
report = agg.analyze_from_dataframes(XAUUSD, dfs, use_news=False)
|
||||
px = report.current_price
|
||||
# Direction only drives the `agrees` label; this fixture's score
|
||||
# is bearish, so a SELL keeps the labels meaningful.
|
||||
setup = TradeSetup(
|
||||
pair=XAUUSD, display_name="XAU/USD", direction="SELL",
|
||||
entry_price=px, stop_loss=px + 6.0, take_profit_1=px - 12.0,
|
||||
take_profit_2=px - 20.0, risk_reward=2.0, risk_pips=60.0,
|
||||
reward_pips=120.0, pip_value=0.10, strategy="XAU_ATR")
|
||||
_M33_CACHE["report"] = (cfg, agg, report, setup)
|
||||
return _M33_CACHE["report"]
|
||||
|
||||
|
||||
def _m33_snapshot():
|
||||
"""snapshot_report() of the cached real report."""
|
||||
cfg, _agg, report, setup = _m33_report()
|
||||
return _rr.snapshot_report(
|
||||
report, setup, pd.Timestamp("2026-07-08 17:00", tz="UTC"),
|
||||
Timeframe.M5, [Timeframe.M15, Timeframe.H1])
|
||||
|
||||
|
||||
def _m33_hand_analysis():
|
||||
"""A TechnicalAnalysis with exactly 4 VOTING indicators + a weight-0
|
||||
ATR, pushed through the real _compute_composite so the effective
|
||||
weight / weighted score stamps are the engine's own."""
|
||||
cfg, _agg, _rep, _setup = _m33_report()
|
||||
result = TechnicalAnalysis(
|
||||
symbol=XAUUSD, timeframe=Timeframe.M5.value,
|
||||
timestamp="2026-07-08 16:55:00+00:00",
|
||||
indicators={
|
||||
"ema_trend": IndicatorSignal("EMA", 1.0, SignalStrength.BUY,
|
||||
60.0, "ema up", 1.0),
|
||||
"rsi": IndicatorSignal("RSI", 55.0, SignalStrength.NEUTRAL,
|
||||
50.0, "rsi mid", 1.0),
|
||||
"macd": IndicatorSignal("MACD", 0.4, SignalStrength.BUY,
|
||||
70.0, "macd up", 1.5),
|
||||
"keltner": IndicatorSignal("KC", 2.0, SignalStrength.SELL,
|
||||
55.0, "kc high", 1.5),
|
||||
# Context only (weight 0) — the composite skips it, so the
|
||||
# recorder must too.
|
||||
"atr": IndicatorSignal("ATR", 5.0, SignalStrength.NEUTRAL,
|
||||
50.0, "atr", 0.0),
|
||||
},
|
||||
market_regime="ranging", volatility_regime="normal",
|
||||
key_levels={"atr": 5.0, "atr_pct": 0.15})
|
||||
TechnicalEngine(cfg.technical)._compute_composite(result)
|
||||
return result
|
||||
|
||||
|
||||
def _m33_duck_report(analysis):
|
||||
"""Minimal duck-typed report carrying every attribute snapshot_report
|
||||
and add_bar_row read (they never touch anything else)."""
|
||||
return _types33.SimpleNamespace(
|
||||
timestamp="2026-07-08 16:55:00+00:00",
|
||||
primary_analysis=analysis, confirmation_analyses=[],
|
||||
final_signal=SignalStrength.BUY, final_score=22.0,
|
||||
final_confidence=58.0, signal_quality="B",
|
||||
mtf_agreement=False, mtf_signals={}, mtf_scores={},
|
||||
penalty_tags={}, penalty_total=0.0, momentum_fading=False,
|
||||
exhaustion_count=0, spread_estimate=0.30, current_price=3300.0,
|
||||
warnings=["a warning"], cleaned_warnings=[],
|
||||
bullish_reasons=["b"], bearish_reasons=[], trade_setup=None)
|
||||
|
||||
|
||||
def _m33_cal_row(row_id, ts, name, currency="USD", impact="high",
|
||||
masked=False, label="1:30pm"):
|
||||
"""One Forex-Factory calendar row in the parquet's own schema."""
|
||||
return {
|
||||
"id": row_id, "dateline": int(pd.Timestamp(ts).timestamp()),
|
||||
"name": name, "country": currency[:2], "currency": currency,
|
||||
"impactName": impact, "timeLabel": label, "timeMasked": masked,
|
||||
"actual": "1.0", "forecast": "0.9", "previous": "0.8",
|
||||
"revision": "", "actualBetterWorse": 1,
|
||||
}
|
||||
|
||||
|
||||
def _m33_span_frames(drift=0.8):
|
||||
"""600 M5 bars + long-enough M15/H1 frames that produce candidates."""
|
||||
return {
|
||||
Timeframe.M5: make_xau_bars(n=600, freq="5min",
|
||||
end="2026-07-08 16:55", drift=drift, seed=7),
|
||||
Timeframe.M15: make_xau_bars(n=300, freq="15min",
|
||||
end="2026-07-08 16:45", drift=drift, seed=8),
|
||||
Timeframe.H1: make_xau_bars(n=300, freq="60min",
|
||||
end="2026-07-08 16:00", drift=drift, seed=9),
|
||||
}
|
||||
|
||||
|
||||
@test("replay record: snapshot reproduces the engine's weights and composite on a real report")
|
||||
def _():
|
||||
cfg, _agg, report, setup = _m33_report()
|
||||
snap = _m33_snapshot()
|
||||
|
||||
assert [b["role"] for b in snap["tfs"]] == ["primary", "c1", "c2"], \
|
||||
[b["role"] for b in snap["tfs"]]
|
||||
assert [b["tf"] for b in snap["tfs"]] == ["5min", "15min", "1h"], \
|
||||
[b["tf"] for b in snap["tfs"]]
|
||||
|
||||
table = regime_weight_table(cfg.technical)
|
||||
analyses = [report.primary_analysis] + list(report.confirmation_analyses)
|
||||
for block, analysis in zip(snap["tfs"], analyses):
|
||||
# The stored set is exactly the composite's voting set.
|
||||
voting = {name for name, ind in analysis.indicators.items()
|
||||
if not name.startswith("_") and ind.weight != 0}
|
||||
assert set(block["indicators"]) == voting, \
|
||||
(block["role"], sorted(block["indicators"]), sorted(voting))
|
||||
mults = table.get(analysis.market_regime, {})
|
||||
for name, stored in block["indicators"].items():
|
||||
ind = analysis.indicators[name]
|
||||
expect_eff = stored["base_weight"] * mults.get(name, 1.0)
|
||||
assert abs(stored["eff_weight"] - expect_eff) < 1e-9, \
|
||||
(name, stored["eff_weight"], expect_eff)
|
||||
expect_ws = (stored["signal"] * stored["eff_weight"]
|
||||
* stored["confidence"] / 100.0)
|
||||
assert abs(stored["wscore"] - expect_ws) < 1e-9, \
|
||||
(name, stored["wscore"], expect_ws)
|
||||
assert abs(stored["regime_mult"] * stored["base_weight"]
|
||||
- stored["eff_weight"]) < 1e-9, name
|
||||
assert stored["signal"] == ind.signal.value
|
||||
|
||||
# Σ wscore / Σ eff_weight reproduces the composite exactly.
|
||||
effs = [v["eff_weight"] for v in block["indicators"].values()]
|
||||
wscores = [v["wscore"] for v in block["indicators"].values()]
|
||||
score, sig = _rr.composite_from_rows(effs, wscores)
|
||||
assert abs(score - analysis.composite_score) < 1e-9, \
|
||||
(block["role"], score, analysis.composite_score)
|
||||
assert sig == analysis.composite_signal.value, \
|
||||
(block["role"], sig, analysis.composite_signal.value)
|
||||
|
||||
# Plain data only: it must survive JSON and the worker pickle boundary.
|
||||
json.dumps(snap)
|
||||
assert _pickle33.loads(_pickle33.dumps(snap)) == snap
|
||||
# bar_ts is the bar LABEL (what signal_log stores), not wall clock.
|
||||
assert snap["bar_ts"] == str(report.primary_analysis.timestamp), \
|
||||
(snap["bar_ts"], report.primary_analysis.timestamp)
|
||||
assert snap["setup"]["strategy"] == "XAU_ATR"
|
||||
|
||||
|
||||
@test("replay record: an indicator that did not fire is no vote, not a zero")
|
||||
def _():
|
||||
# Divergence and candlestick only exist when they fire. A zero row
|
||||
# would read as a NEUTRAL vote and drag every study's average.
|
||||
analysis = _m33_hand_analysis()
|
||||
report = _m33_duck_report(analysis)
|
||||
setup = _types33.SimpleNamespace(
|
||||
direction="BUY", strategy="XAU_ATR", setup_pattern=None,
|
||||
round_trip_cost=0.30, trail_arm_r=0.6, trail_dist_r=0.8,
|
||||
entry_price=3300.0, stop_loss=3294.0, take_profit_1=3312.0,
|
||||
take_profit_2=3320.0, risk_reward=2.0)
|
||||
snap = _rr.snapshot_report(
|
||||
report, setup, pd.Timestamp("2026-07-08 17:00", tz="UTC"),
|
||||
Timeframe.M5, [Timeframe.M15, Timeframe.H1])
|
||||
assert set(snap["tfs"][0]["indicators"]) == {
|
||||
"ema_trend", "rsi", "macd", "keltner"}, sorted(snap["tfs"][0]["indicators"])
|
||||
|
||||
trade = _m29_trade()
|
||||
trade.snapshot = snap
|
||||
ind_df = _rr.build_indicators_table([trade])
|
||||
got = set(ind_df["indicator"])
|
||||
assert got == {"ema_trend", "rsi", "macd", "keltner"}, sorted(got)
|
||||
for absent in ("candlestick", "divergence", "atr"):
|
||||
assert absent not in got, f"{absent} must have NO row at all"
|
||||
|
||||
# The per-bar float32 row: absent indicator / absent TF stays NaN.
|
||||
buf = _rr.new_bar_buffer()
|
||||
_rr.add_bar_row(buf, 5, pd.Timestamp("2026-07-08 16:55", tz="UTC"),
|
||||
report, Timeframe.M5, [Timeframe.M15, Timeframe.H1])
|
||||
mat, ts_ns, pos, warn0 = _rr.finish_bar_rows(buf)
|
||||
bars = pd.DataFrame(mat, columns=list(_rr.BAR_COLS))
|
||||
cs_cols = [c for c in _rr.BAR_COLS if c.startswith("primary_candlestick_")]
|
||||
assert len(cs_cols) == 5, cs_cols
|
||||
assert bars[cs_cols].isna().all(axis=None), bars[cs_cols].iloc[0].tolist()
|
||||
conf_cols = [c for c in _rr.BAR_COLS
|
||||
if c.startswith("c1_") or c.startswith("c2_")]
|
||||
assert bars[conf_cols].isna().all(axis=None), \
|
||||
"a missing confirmation block must be NaN, never 0"
|
||||
assert warn0 == ["a warning"] and list(pos) == [5]
|
||||
|
||||
effs = [v["eff_weight"] for v in snap["tfs"][0]["indicators"].values()]
|
||||
wscores = [v["wscore"] for v in snap["tfs"][0]["indicators"].values()]
|
||||
score, sig = _rr.composite_from_rows(effs, wscores)
|
||||
assert abs(score - analysis.composite_score) < 1e-9, \
|
||||
(score, analysis.composite_score)
|
||||
assert sig == analysis.composite_signal.value
|
||||
|
||||
|
||||
@test("replay record: Trade.snapshot defaults empty, is not shared, and pickles")
|
||||
def _():
|
||||
t1, t2 = _m29_trade(), _m29_trade()
|
||||
assert t1.snapshot == {}, t1.snapshot
|
||||
assert t2.snapshot == {}, t2.snapshot
|
||||
assert t1.snapshot is not t2.snapshot, "mutable default trap"
|
||||
t1.snapshot = {"tfs": [{"role": "primary", "indicators": {"rsi": {"wscore": 1.0}}}]}
|
||||
back = _pickle33.loads(_pickle33.dumps(t1))
|
||||
assert back.snapshot == t1.snapshot, back.snapshot
|
||||
assert t2.snapshot == {}, "a snapshot on one trade must not reach another"
|
||||
|
||||
|
||||
@test("replay record: trades_to_dataframe ignores the snapshot field (63 columns, identical frame)")
|
||||
def _():
|
||||
# The pinned books must stay byte-identical with recording on.
|
||||
plain = _m29_trade(r_multiple=1.5)
|
||||
recorded = _m29_trade(r_multiple=1.5)
|
||||
recorded.snapshot = _m33_snapshot()
|
||||
off = _gb33.trades_to_dataframe([plain])
|
||||
on = _gb33.trades_to_dataframe([recorded])
|
||||
assert len(off.columns) == 63, len(off.columns)
|
||||
assert "snapshot" not in off.columns and "snapshot" not in on.columns
|
||||
pd.testing.assert_frame_equal(off, on)
|
||||
|
||||
|
||||
@test("replay record: composite thresholds match the engine")
|
||||
def _():
|
||||
# One indicator, base weight 1.0, regime "unknown" (no multiplier
|
||||
# table entry -> x1.0), so composite = signal x confidence / 2.
|
||||
tech = TechnicalConfig()
|
||||
for target in (39.99, 40.0, 14.99, 15.0, -14.99, -15.0, -39.99, -40.0, 0.0):
|
||||
signal = SignalStrength.BUY if target >= 0 else SignalStrength.SELL
|
||||
result = TechnicalAnalysis(
|
||||
symbol=XAUUSD, timeframe=Timeframe.M5.value, timestamp="t",
|
||||
indicators={"rsi": IndicatorSignal(
|
||||
"RSI", 50.0, signal, abs(target) * 2.0, "", 1.0)},
|
||||
market_regime="unknown")
|
||||
TechnicalEngine(tech)._compute_composite(result)
|
||||
assert abs(result.composite_score - target) < 1e-9, \
|
||||
(target, result.composite_score)
|
||||
ind = result.indicators["rsi"]
|
||||
assert abs(ind.effective_weight - 1.0) < 1e-9, ind.effective_weight
|
||||
score, sig = _rr.composite_from_rows(
|
||||
[ind.effective_weight], [ind.weighted_score])
|
||||
assert abs(score - result.composite_score) < 1e-9, (score, target)
|
||||
assert sig == result.composite_signal.value, \
|
||||
(target, sig, result.composite_signal.value)
|
||||
# Both sides of both thresholds really were exercised.
|
||||
assert _rr.COMPOSITE_THRESHOLDS == {"strong": 40.0, "weak": 15.0}
|
||||
|
||||
|
||||
@test("replay record: calendar join — window edges, phases, all-day, holiday, offsets")
|
||||
def _():
|
||||
entry = pd.Timestamp("2026-09-07 13:00", tz="UTC") # Monday
|
||||
exit_ts = entry + pd.Timedelta(hours=1)
|
||||
# Window = [entry-4h, max(exit, entry+4h)] = [09:00, 17:00], inclusive.
|
||||
rows = {
|
||||
"A": _m33_cal_row(1, entry - pd.Timedelta(hours=4), "A prev release"),
|
||||
"B": _m33_cal_row(2, entry - pd.Timedelta(hours=4, seconds=60),
|
||||
"B outside window"),
|
||||
"C": _m33_cal_row(3, entry + pd.Timedelta(minutes=30), "C in hold"),
|
||||
# medium impact: in `events` as after_exit, but never counted high.
|
||||
"D": _m33_cal_row(4, exit_ts + pd.Timedelta(hours=2), "D after exit",
|
||||
impact="medium"),
|
||||
"E": _m33_cal_row(5, entry + pd.Timedelta(hours=25), "E far ahead"),
|
||||
"F": _m33_cal_row(6, entry + pd.Timedelta(minutes=45), "F eur low",
|
||||
currency="EUR", impact="low"),
|
||||
# All-day US holiday: its dateline is 2026-09-08 00:30 UTC =
|
||||
# 2026-09-07 20:30 ET, so it joins on the entry's ET date.
|
||||
"G": _m33_cal_row(7, pd.Timestamp("2026-09-08 00:30", tz="UTC"),
|
||||
"Bank Holiday", impact="holiday", masked=True,
|
||||
label="All Day"),
|
||||
# A TIMED holiday row is not an all-day closure and must not
|
||||
# flag us_holiday for a trade entered that day.
|
||||
"H": _m33_cal_row(8, pd.Timestamp("2026-11-02 14:00", tz="UTC"),
|
||||
"Daylight Saving Time Shift", impact="holiday"),
|
||||
"I": _m33_cal_row(9, entry + pd.Timedelta(minutes=20), "I imminent"),
|
||||
}
|
||||
trades_df = pd.DataFrame({
|
||||
"trade_id": [0, 1],
|
||||
"entry_time": pd.to_datetime(
|
||||
[entry, pd.Timestamp("2026-11-02 12:00", tz="UTC")], utc=True),
|
||||
# Trade 1 is still open: exit NaT must not raise.
|
||||
"exit_time": pd.to_datetime([exit_ts, pd.NaT], utc=True),
|
||||
})
|
||||
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
full_path = os.path.join(d, "cal_full.parquet")
|
||||
pd.DataFrame([rows[k] for k in "ABCDEFGHI"]).to_parquet(
|
||||
full_path, index=False)
|
||||
# Same calendar minus every USD-high release inside 24h AFTER the
|
||||
# entry, so the "no next release" branch is reachable.
|
||||
quiet_path = os.path.join(d, "cal_quiet.parquet")
|
||||
pd.DataFrame([rows[k] for k in "ABDEFGH"]).to_parquet(
|
||||
quiet_path, index=False)
|
||||
|
||||
cal = _rr.load_calendar(full_path)
|
||||
events = _rr.build_events_table(trades_df, cal)
|
||||
assert str(events["ts_utc"].dtype) == "datetime64[ns, UTC]", \
|
||||
events["ts_utc"].dtype
|
||||
t0 = events[events["trade_id"] == 0]
|
||||
phases = dict(zip(t0["name"], t0["phase"]))
|
||||
assert phases == {
|
||||
"A prev release": "before", "I imminent": "during_hold",
|
||||
"C in hold": "during_hold", "F eur low": "during_hold",
|
||||
"D after exit": "after_exit", "Bank Holiday": "all_day",
|
||||
}, phases
|
||||
assert "B outside window" not in phases, "entry-4h-60s is outside"
|
||||
assert "E far ahead" not in phases, "entry+25h is outside"
|
||||
offsets = dict(zip(t0["name"], t0["offset_min"]))
|
||||
assert offsets["A prev release"] == -240.0, offsets["A prev release"]
|
||||
assert offsets["I imminent"] == 20.0
|
||||
assert offsets["D after exit"] == 180.0
|
||||
assert np.isnan(offsets["Bank Holiday"]), "all-day rows have no offset"
|
||||
timed = dict(zip(t0["name"], t0["timed"]))
|
||||
assert timed["Bank Holiday"] is np.False_ or timed["Bank Holiday"] == False
|
||||
assert timed["A prev release"] == True
|
||||
|
||||
scal = _rr.calendar_scalars(trades_df, cal)
|
||||
assert scal["ev_prev_hi_usd_min"].iloc[0] == 240.0
|
||||
assert scal["ev_prev_hi_usd_name"].iloc[0] == "A prev release"
|
||||
assert scal["ev_next_hi_usd_min"].iloc[0] == 20.0
|
||||
assert scal["ev_next_hi_usd_name"].iloc[0] == "I imminent"
|
||||
assert bool(scal["ev_entry_within_30m_hi_usd"].iloc[0]) is True
|
||||
# I and C are USD-high inside the hold; F (EUR low) and D
|
||||
# (USD medium) are not.
|
||||
assert scal["ev_hi_usd_during_hold"].iloc[0] == 2, \
|
||||
scal["ev_hi_usd_during_hold"].iloc[0]
|
||||
assert scal["ev_hi_usd_during_hold_names"].iloc[0] == \
|
||||
"I imminent|C in hold"
|
||||
assert scal["ev_any_hi_during_hold"].iloc[0] == 2
|
||||
assert bool(scal["us_holiday"].iloc[0]) is True, \
|
||||
"the all-day USD holiday shares the entry's ET date"
|
||||
# Open trade: no exit -> no hold window, and no crash.
|
||||
assert scal["ev_hi_usd_during_hold"].iloc[1] == 0
|
||||
assert bool(scal["us_holiday"].iloc[1]) is False, \
|
||||
"a TIMED holiday row must not flag us_holiday"
|
||||
|
||||
# Quiet calendar: the nearest USD-high after entry is 25h out,
|
||||
# beyond NEAR_RELEASE_MAX_MIN, so both branches read empty.
|
||||
quiet = _rr.load_calendar(quiet_path)
|
||||
scal_q = _rr.calendar_scalars(trades_df, quiet)
|
||||
assert scal_q["ev_prev_hi_usd_min"].iloc[0] == 240.0
|
||||
assert np.isnan(scal_q["ev_next_hi_usd_min"].iloc[0]), \
|
||||
scal_q["ev_next_hi_usd_min"].iloc[0]
|
||||
assert scal_q["ev_next_hi_usd_name"].iloc[0] == ""
|
||||
assert bool(scal_q["ev_entry_within_30m_hi_usd"].iloc[0]) is False
|
||||
assert scal_q["ev_hi_usd_during_hold"].iloc[0] == 0
|
||||
|
||||
|
||||
@test("replay record: write_run + load_run round-trip with a full manifest")
|
||||
def _():
|
||||
cfg, agg, _report, _setup = _m33_report()
|
||||
snap = _m33_snapshot()
|
||||
trades = [_m29_trade(r_multiple=1.5), _m29_trade(r_multiple=-1.0)]
|
||||
for t in trades:
|
||||
t.snapshot = copy.deepcopy(snap)
|
||||
book_df = _gb33.trades_to_dataframe(trades)
|
||||
assert len(book_df.columns) == 63, len(book_df.columns)
|
||||
|
||||
run_info = dict(first_pos=250, last_pos=400, n_primary_bars=400,
|
||||
n_bars_in_window=150, skipped={"SESSION_WINDOW": 10},
|
||||
n_candidates=3, workers=1, backend="NumPy CPU fallback",
|
||||
cache_files={"5min": "x.parquet"}, replay_seconds=0.1)
|
||||
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
# NEVER read backtest/cache/calendar.parquet from a test.
|
||||
cal_path = os.path.join(d, "cal.parquet")
|
||||
pd.DataFrame([_m33_cal_row(
|
||||
1, pd.Timestamp("2026-01-05 13:30", tz="UTC"),
|
||||
"Non-Farm Employment Change")]).to_parquet(cal_path, index=False)
|
||||
rec_dir = os.path.join(d, "run")
|
||||
bt_cfg = _gb33.BacktestConfig(
|
||||
pair=XAUUSD, start=datetime(2026, 7, 1), end=datetime(2026, 7, 8),
|
||||
primary_tf=Timeframe.M5,
|
||||
confirmation_tfs=[Timeframe.M15, Timeframe.H1],
|
||||
spread=0.65, record_dir=rec_dir)
|
||||
saved_cal = _rr.CALENDAR_PARQUET
|
||||
try:
|
||||
_rr.CALENDAR_PARQUET = cal_path
|
||||
_rr.write_run(rec_dir, trades=trades, book_df=book_df,
|
||||
bt_cfg=bt_cfg, platform_cfg=cfg, aggregator=agg,
|
||||
run_info=run_info)
|
||||
finally:
|
||||
_rr.CALENDAR_PARQUET = saved_cal
|
||||
|
||||
run = _rr.load_run(rec_dir)
|
||||
man = run["manifest"]
|
||||
assert man["engine_fingerprint"] == agg.engine_config_fingerprint()
|
||||
assert man["gate_fingerprint"] == agg.gate_config_fingerprint()
|
||||
assert man["regime_weight_table"] == regime_weight_table(cfg.technical)
|
||||
assert man["base_weights_observed"], "base weights must be recorded"
|
||||
for name, seen in man["base_weights_observed"].items():
|
||||
assert set(seen) <= {0.5, 1.0, 1.5}, (name, seen)
|
||||
assert man["calendar"]["md5"] == _hashlib33.md5(
|
||||
open(cal_path, "rb").read()).hexdigest()
|
||||
expect_md5 = _hashlib33.md5(
|
||||
book_df.to_csv(index=False).encode()).hexdigest()
|
||||
assert man["book_csv_md5"] == expect_md5, man["book_csv_md5"]
|
||||
csv_path = os.path.join(d, "book.csv")
|
||||
book_df.to_csv(csv_path, index=False)
|
||||
assert expect_md5 == _hashlib33.md5(
|
||||
open(csv_path, "rb").read()).hexdigest(), \
|
||||
"the manifest md5 must match the exported CSV byte-for-byte"
|
||||
assert man["n_trades"] == 2, man["n_trades"]
|
||||
assert man["spread"] == 0.65
|
||||
assert man["tables"]["bars"] is None, "no --record-bars in this run"
|
||||
|
||||
trades_tbl = run["trades"]
|
||||
assert trades_tbl.columns[0] == "trade_id", trades_tbl.columns[0]
|
||||
cols = list(trades_tbl.columns)
|
||||
book_cols = list(book_df.columns)
|
||||
start = cols.index(book_cols[0])
|
||||
assert cols[start:start + len(book_cols)] == book_cols, \
|
||||
"the 63 book columns must stay a contiguous, ordered slice"
|
||||
assert str(run["indicators"]["signal"].dtype) == "int8", \
|
||||
run["indicators"]["signal"].dtype
|
||||
assert run["indicators"]["signal"].dtype == np.int8
|
||||
assert str(trades_tbl["entry_time"].dtype) == "datetime64[ns, UTC]", \
|
||||
trades_tbl["entry_time"].dtype
|
||||
assert isinstance(run["events"], pd.DataFrame), type(run["events"])
|
||||
assert run["bars"] is None, "bars.parquet must not exist"
|
||||
|
||||
|
||||
@test("replay record: _replay_span with --record returns the same candidates plus snapshots")
|
||||
def _():
|
||||
from pair_signals.xauusd import make_config as _mk33
|
||||
cfg = _mk33(Timeframe.M5, [Timeframe.M15, Timeframe.H1])
|
||||
dfs_full = _m33_span_frames()
|
||||
caches = {tf.value: _gb33.precompute_indicators(df, cfg.technical)
|
||||
for tf, df in dfs_full.items()}
|
||||
agg = SignalAggregator(cfg)
|
||||
agg.tech_engine = _gb33.GPUTechnicalEngine(cfg.technical, caches)
|
||||
bt_off = _gb33.BacktestConfig(
|
||||
pair=XAUUSD, start=datetime(2026, 7, 6), end=datetime(2026, 7, 9),
|
||||
primary_tf=Timeframe.M5,
|
||||
confirmation_tfs=[Timeframe.M15, Timeframe.H1])
|
||||
bt_on = _dc33.replace(bt_off, record_dir="unused", record_bars=True)
|
||||
span = (300, 600)
|
||||
try:
|
||||
_gb33._REPLAY_CTX.update(
|
||||
primary_df=dfs_full[Timeframe.M5], dfs_full=dfs_full,
|
||||
timeframes=[Timeframe.M5, Timeframe.M15, Timeframe.H1],
|
||||
aggregator=agg, platform_cfg=cfg, bt_cfg=bt_off,
|
||||
primary_dur=pd.Timedelta(minutes=5))
|
||||
skip_off, cands_off, bars_off = _gb33._replay_span(span)
|
||||
_gb33._REPLAY_CTX["bt_cfg"] = bt_on
|
||||
skip_on, cands_on, bars_on = _gb33._replay_span(span)
|
||||
finally:
|
||||
_gb33._REPLAY_CTX.clear()
|
||||
|
||||
assert len(cands_off) > 0, "the fixture must produce candidates"
|
||||
assert skip_off == skip_on, (skip_off, skip_on)
|
||||
assert [p for p, _ in cands_off] == [p for p, _ in cands_on], \
|
||||
"recording must not move a single entry"
|
||||
for (_p_off, t_off), (_p_on, t_on) in zip(cands_off, cands_on):
|
||||
d_off = _dc33.asdict(t_off)
|
||||
d_on = _dc33.asdict(t_on)
|
||||
d_off.pop("snapshot")
|
||||
snap = d_on.pop("snapshot")
|
||||
differing = [k for k in d_off if d_off[k] != d_on[k]]
|
||||
assert not differing, differing
|
||||
assert t_off.snapshot == {}, "recording off must leave the field empty"
|
||||
assert snap and len(snap["tfs"]) == 3, \
|
||||
"a recorded trade carries primary + both confirmation blocks"
|
||||
|
||||
assert bars_off is None, "no bar chunk without --record-bars"
|
||||
mat, ts_ns, pos, warn0 = bars_on
|
||||
assert mat.dtype == np.float32, mat.dtype
|
||||
assert mat.shape[1] == len(_rr.BAR_COLS), mat.shape
|
||||
n_analyzed = (span[1] - span[0]) - sum(skip_off.values())
|
||||
assert mat.shape[0] == n_analyzed, (mat.shape[0], n_analyzed)
|
||||
assert len(cands_on) <= mat.shape[0] <= (span[1] - span[0])
|
||||
assert len(ts_ns) == len(pos) == len(warn0) == mat.shape[0]
|
||||
|
||||
|
||||
@test("replay record: forward moves on a hand-built series")
|
||||
def _():
|
||||
close = np.array([10.0, 11.0, 13.0, 12.0, 15.0, 14.0])
|
||||
high, low = close + 1.0, close - 1.0
|
||||
atr = np.array([1.0, 1.0, 1.0, 1.0, 1.0, 0.0])
|
||||
mv = _rr.forward_moves(close, high, low, atr, 2)
|
||||
assert mv["fwd"][0] == 3.0, mv["fwd"][0]
|
||||
assert mv["mfe"][0] == 4.0, mv["mfe"][0] # max(11,13)+1 - 10
|
||||
assert mv["mae"][0] == 0.0, mv["mae"][0] # 10 - (min(11,13)-1)
|
||||
assert mv["fwd"][3] == 2.0, mv["fwd"][3]
|
||||
for i in (4, 5):
|
||||
for key in ("fwd", "mfe", "mae"):
|
||||
assert np.isnan(mv[key][i]), (key, i, mv[key][i])
|
||||
# A zero ATR blanks its own row even with a full horizon ahead.
|
||||
mv2 = _rr.forward_moves(close, high, low,
|
||||
np.array([1.0, 0.0, 1.0, 1.0, 1.0, 1.0]), 2)
|
||||
assert np.isnan(mv2["fwd"][1]) and np.isnan(mv2["mfe"][1]) \
|
||||
and np.isnan(mv2["mae"][1]), mv2["fwd"][1]
|
||||
assert mv2["fwd"][0] == 3.0 and mv2["fwd"][2] == 2.0
|
||||
|
||||
|
||||
@test("replay record: --record/--record-bars parse, default None/False, bars need record")
|
||||
def _():
|
||||
args = _gb33._parse_args(["--pair", "XAUUSD"])
|
||||
assert args.record is None, args.record
|
||||
assert args.record_bars is False, args.record_bars
|
||||
assert _gb33._parse_args(
|
||||
["--pair", "XAUUSD", "--record", "/tmp/x"]).record == "/tmp/x"
|
||||
try:
|
||||
with open(os.devnull, "w") as _null, _ctx33.redirect_stderr(_null):
|
||||
_gb33._parse_args(["--pair", "XAUUSD", "--record-bars"])
|
||||
raise AssertionError("--record-bars without --record must exit")
|
||||
except SystemExit:
|
||||
pass
|
||||
bare = _gb33.BacktestConfig(pair=XAUUSD, start=datetime(2026, 7, 1),
|
||||
end=datetime(2026, 7, 8))
|
||||
assert bare.record_dir is None and bare.record_bars is False
|
||||
|
||||
|
||||
@test("replay record: replay_record imports none of the live writers or the backtester")
|
||||
def _():
|
||||
# The recorder runs inside replay workers and must stay a leaf: no
|
||||
# live writer, no news, no provider, and no import cycle back into
|
||||
# the backtester.
|
||||
banned = {"signal_log", "news_engine", "providers", "telegram_bot",
|
||||
"news_archive", "agent_panel", "violent_watch",
|
||||
"market_outlook", "gpu_backtest", "sklearn", "scipy",
|
||||
"lightgbm", "torch"}
|
||||
path = Path("backtest/replay_record.py")
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"), str(path))
|
||||
seen, offenders = set(), []
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
names = [a.name for a in node.names]
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
names = [node.module or ""]
|
||||
else:
|
||||
continue
|
||||
for name in names:
|
||||
root = name.split(".")[0]
|
||||
seen.add(root)
|
||||
if root in banned:
|
||||
offenders.append(f"{path}:{node.lineno} {name}")
|
||||
assert not offenders, f"replay_record must stay a leaf module: {offenders}"
|
||||
# ...and the guard is not vacuous — it really does import these two.
|
||||
assert "engine" in seen and "config" in seen, sorted(seen)
|
||||
|
||||
|
||||
@test("replay record: the backtester still satisfies its source guards with the recorder wired in")
|
||||
def _():
|
||||
src = open(_gb33.__file__).read()
|
||||
for banned in ("import signal_log", "scan_outcomes", "label_scan_outcomes",
|
||||
"update_watch", "log_watch_update", "NewsEngine(",
|
||||
"news_archive", "import violent_watch", "from violent_watch"):
|
||||
assert banned not in src, f"backtests must stay write-free: {banned}"
|
||||
for required in ("use_news=False",
|
||||
"_apply_regime_filter_live(report, slice_dfs)",
|
||||
"from backtest import replay_record", "snapshot=snap"):
|
||||
assert required in src, f"missing: {required}"
|
||||
assert "_compute_composite" not in _gb33.GPUTechnicalEngine.__dict__, \
|
||||
"the GPU engine must inherit the live composite, never shadow it"
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
# SUMMARY
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user