"""Trade-book diff: relaxed/gated run vs its control. Usage: python backtest/research/book_diff.py CONTROL.csv CANDIDATE.csv BAR_MINUTES Decision numbers are RUN-LEVEL (ΔtotalR, ΔPF, ΔmaxDD, Δtrades) — never the added-trade bucket alone: the 5-bar entry cooldown means new entries displace existing ones. The added/removed decomposition and the per-decision-hour histogram of added trades are diagnostics. Shifted trades = removed+added pairs in the same direction within ±5 bars (cooldown displacement, not a genuinely new/lost signal). """ import sys import pandas as pd def _metrics(df): r = df["r_multiple"] gains, losses = r[r > 0].sum(), -r[r <= 0].sum() cum = r.cumsum() return dict(trades=len(df), total_r=r.sum(), pf=gains / max(losses, 1e-9), wr=(r > 0).mean() * 100 if len(df) else 0.0, max_dd=(cum.cummax() - cum).max() if len(cum) else 0.0) def diff_books(control_csv, candidate_csv, bar_minutes, split="2026-01-29"): ctrl, cand = pd.read_csv(control_csv), pd.read_csv(candidate_csv) for df in (ctrl, cand): df["_t"] = pd.to_datetime(df["entry_time"], utc=True, format="mixed") df["_key"] = df["entry_time"].astype(str) + "|" + df["direction"] added = cand[~cand["_key"].isin(ctrl["_key"])].copy() removed = ctrl[~ctrl["_key"].isin(cand["_key"])].copy() # cooldown displacement: same-direction removed/added within ±5 bars tol = pd.Timedelta(minutes=5 * bar_minutes) shifted = 0 rem_pool = removed.copy() for _, a in added.iterrows(): m = rem_pool[(rem_pool["direction"] == a["direction"]) & ((rem_pool["_t"] - a["_t"]).abs() <= tol)] if len(m): shifted += 1 rem_pool = rem_pool.drop(m.index[0]) mc, mk = _metrics(ctrl), _metrics(cand) split_ts = pd.Timestamp(split, tz="UTC") halves = (cand[cand["_t"] < split_ts]["r_multiple"].sum(), cand[cand["_t"] >= split_ts]["r_multiple"].sum()) hour_hist = None if len(added): added["dec_hour"] = (added["_t"] - pd.Timedelta(minutes=bar_minutes)).dt.hour hour_hist = added.groupby("dec_hour")["r_multiple"] \ .agg(["count", "sum"]).round(2) return dict( control=mc, candidate=mk, delta_r=mk["total_r"] - mc["total_r"], delta_pf=mk["pf"] - mc["pf"], delta_dd=mk["max_dd"] - mc["max_dd"], delta_trades=mk["trades"] - mc["trades"], added_n=len(added), added_r=added["r_multiple"].sum(), removed_n=len(removed), removed_r=removed["r_multiple"].sum(), shifted=shifted, halves=halves, added_by_hour=hour_hist, ) if __name__ == "__main__": d = diff_books(sys.argv[1], sys.argv[2], int(sys.argv[3])) c, k = d["control"], d["candidate"] print(f"control : {c['trades']:4d} trades {c['total_r']:+7.1f}R " f"PF {c['pf']:.2f} WR {c['wr']:.1f}% DD {c['max_dd']:.1f}") print(f"candidate: {k['trades']:4d} trades {k['total_r']:+7.1f}R " f"PF {k['pf']:.2f} WR {k['wr']:.1f}% DD {k['max_dd']:.1f}") print(f"ΔR {d['delta_r']:+.1f} ΔPF {d['delta_pf']:+.2f} " f"ΔDD {d['delta_dd']:+.1f} Δtrades {d['delta_trades']:+d}") print(f"added {d['added_n']} ({d['added_r']:+.1f}R) " f"removed {d['removed_n']} ({d['removed_r']:+.1f}R) " f"shifted≈{d['shifted']}") print(f"candidate halves: H1 {d['halves'][0]:+.1f}R H2 {d['halves'][1]:+.1f}R") if d["added_by_hour"] is not None: print("\nadded trades by decision hour:") print(d["added_by_hour"].to_string())