#!/usr/bin/env python3 """ Phase 1 analysis: post-hoc filters on an existing trades.csv. Asks "which simple filters, applied to the trades the engine already produced, would have moved total R toward zero or positive?" — without re-running the backtest engine. Discipline: candidate filters are derived on the **first 70% of trades by entry_time** (train slice), then evaluated on the **last 30%** (test slice). This is the same time-based split the meta-model uses, so the numbers are comparable. Outputs (printed + saved to backtest_results_all/phase1_summary.txt): - Session-of-day breakdown - Day-of-week breakdown - Pair × regime cross-tab - Per-pair contribution - Combined-filter sweep (drop-worst-hours / drop-worst-pairs / both) Usage: python analyze_trades.py python analyze_trades.py --trades backtest_results_all/trades.csv """ from __future__ import annotations import argparse import sys from pathlib import Path import pandas as pd def fmt(df: pd.DataFrame) -> pd.DataFrame: return df.round(3) def split(df: pd.DataFrame, train_frac: float = 0.7): df = df.sort_values("entry_time").reset_index(drop=True) cutoff = int(len(df) * train_frac) return df.iloc[:cutoff].copy(), df.iloc[cutoff:].copy() def stats(df: pd.DataFrame) -> dict: if len(df) == 0: return {"n": 0, "wr": 0.0, "avg_r": 0.0, "total_r": 0.0, "pf": 0.0} wins = (df["r_multiple"] > 0).sum() losses = (df["r_multiple"] <= 0).sum() gross_win = df.loc[df["r_multiple"] > 0, "r_multiple"].sum() gross_loss = -df.loc[df["r_multiple"] <= 0, "r_multiple"].sum() return { "n": len(df), "wr": wins / len(df) * 100, "avg_r": df["r_multiple"].mean(), "total_r": df["r_multiple"].sum(), "pf": gross_win / gross_loss if gross_loss > 0 else float("inf"), } def stats_line(label: str, df: pd.DataFrame) -> str: s = stats(df) return ( f"{label:<40s}" f" n={s['n']:>5} WR={s['wr']:5.1f}%" f" avgR={s['avg_r']:+6.3f} totalR={s['total_r']:+9.2f} PF={s['pf']:.2f}" ) def main() -> int: ap = argparse.ArgumentParser(description="Phase 1 post-hoc analysis on backtest trades.") ap.add_argument("--trades", default="backtest_results_all/trades.csv") ap.add_argument("--out", default="backtest_results_all/phase1_summary.txt") ap.add_argument("--train-frac", type=float, default=0.7) args = ap.parse_args() p = Path(args.trades) if not p.exists(): print(f"❌ {p} not found") return 1 df = pd.read_csv(p) df["entry_time"] = pd.to_datetime(df["entry_time"]) df["hour"] = df["entry_time"].dt.hour df["dow"] = df["entry_time"].dt.dayofweek df["winner"] = (df["r_multiple"] > 0).astype(int) train, test = split(df, args.train_frac) out_lines = [] def emit(line: str = ""): print(line) out_lines.append(line) emit("=" * 92) emit(f"PHASE 1 ANALYSIS — {len(df):,} trades total") emit(f" full: {df['entry_time'].min().date()} → {df['entry_time'].max().date()}") emit(f" train: {train['entry_time'].min().date()} → {train['entry_time'].max().date()} ({len(train):,} trades)") emit(f" test: {test['entry_time'].min().date()} → {test['entry_time'].max().date()} ({len(test):,} trades)") emit("=" * 92) emit() emit(stats_line("BASELINE full", df)) emit(stats_line("BASELINE train", train)) emit(stats_line("BASELINE test", test)) emit() # ── Per-hour (training set, used for filter selection) ────────── emit("─" * 92) emit("HOUR (UTC) — train slice (used for filter selection)") emit("─" * 92) hr_stats = train.groupby("hour").apply( lambda x: pd.Series(stats(x)), include_groups=False, ).round(3) emit(hr_stats.to_string()) emit() # Bottom-K hours by avg_r bad_hours_4 = hr_stats.sort_values("avg_r").head(4).index.tolist() bad_hours_6 = hr_stats.sort_values("avg_r").head(6).index.tolist() emit(f" Worst 4 hours by avg_r (train): {sorted(bad_hours_4)}") emit(f" Worst 6 hours by avg_r (train): {sorted(bad_hours_6)}") emit() # ── Per-DOW (training set) ────────────────────────────────────── emit("─" * 92) emit("DAY OF WEEK — train slice (0=Mon, 4=Fri)") emit("─" * 92) dow_stats = train.groupby("dow").apply( lambda x: pd.Series(stats(x)), include_groups=False, ).round(3) emit(dow_stats.to_string()) emit() bad_dow = dow_stats.sort_values("avg_r").head(2).index.tolist() emit(f" Worst 2 days by avg_r (train): {sorted(bad_dow)}") emit() # ── Per-pair (training set) ───────────────────────────────────── emit("─" * 92) emit("PAIR — train slice") emit("─" * 92) pair_stats = train.groupby("pair").apply( lambda x: pd.Series(stats(x)), include_groups=False, ).round(3) emit(pair_stats.to_string()) emit() bad_pairs = pair_stats.sort_values("total_r").head(3).index.tolist() emit(f" Worst 3 pairs by total_r (train): {bad_pairs}") emit() # ── Per-regime × direction (training set) ─────────────────────── emit("─" * 92) emit("REGIME × DIRECTION — train slice") emit("─" * 92) reg_stats = train.groupby(["regime", "direction"]).apply( lambda x: pd.Series(stats(x)), include_groups=False, ).round(3) emit(reg_stats.to_string()) emit() # ── Filter sweep ON THE TEST SET (rule selected on train) ─────── emit("=" * 92) emit("FILTER SWEEP — applied to TEST slice (rule chosen from train, no leakage)") emit("=" * 92) test_filtered_dropworst4hours = test[~test["hour"].isin(bad_hours_4)] test_filtered_dropworst6hours = test[~test["hour"].isin(bad_hours_6)] test_filtered_droppairs = test[~test["pair"].isin(bad_pairs)] test_filtered_droprangingsell = test[~((test["regime"] == "ranging") & (test["direction"] == "SELL"))] test_filtered_only_ranging = test[test["regime"] == "ranging"] test_filtered_drop_dow = test[~test["dow"].isin(bad_dow)] test_combo_hr_pair = test[ (~test["hour"].isin(bad_hours_4)) & (~test["pair"].isin(bad_pairs)) ] test_combo_hr_pair_dow = test[ (~test["hour"].isin(bad_hours_4)) & (~test["pair"].isin(bad_pairs)) & (~test["dow"].isin(bad_dow)) ] emit() emit(stats_line("test baseline (no filter)", test)) emit(stats_line("test drop 4 worst hours", test_filtered_dropworst4hours)) emit(stats_line("test drop 6 worst hours", test_filtered_dropworst6hours)) emit(stats_line("test drop 3 worst pairs", test_filtered_droppairs)) emit(stats_line("test drop 2 worst DOWs", test_filtered_drop_dow)) emit(stats_line("test ONLY ranging regime", test_filtered_only_ranging)) emit(stats_line("test hours+pairs (combined)", test_combo_hr_pair)) emit(stats_line("test hours+pairs+DOW (combined)", test_combo_hr_pair_dow)) emit() # ── Same filters applied to TRAIN slice for reference ─────────── emit("─" * 92) emit("Same filters — TRAIN slice for reference (these are the in-sample numbers)") emit("─" * 92) train_drop_hours = train[~train["hour"].isin(bad_hours_4)] train_drop_pairs = train[~train["pair"].isin(bad_pairs)] train_combo = train[(~train["hour"].isin(bad_hours_4)) & (~train["pair"].isin(bad_pairs))] emit(stats_line("train baseline (no filter)", train)) emit(stats_line("train drop 4 worst hours", train_drop_hours)) emit(stats_line("train drop 3 worst pairs", train_drop_pairs)) emit(stats_line("train hours+pairs (combined)", train_combo)) emit() # ── Verdict ───────────────────────────────────────────────────── emit("=" * 92) emit("VERDICT") emit("=" * 92) test_base = stats(test) test_combo = stats(test_combo_hr_pair_dow) delta_r = test_combo["total_r"] - test_base["total_r"] delta_avgr = test_combo["avg_r"] - test_base["avg_r"] emit(f" Best combined filter (hours+pairs+DOW) on TEST set:") emit(f" {test_base['n']} → {test_combo['n']} trades " f"({test_combo['n']/test_base['n']*100:.0f}% retained)") emit(f" total R: {test_base['total_r']:+.2f} → {test_combo['total_r']:+.2f} " f"(Δ {delta_r:+.2f})") emit(f" avg R: {test_base['avg_r']:+.3f} → {test_combo['avg_r']:+.3f} " f"(Δ {delta_avgr:+.3f})") emit(f" win rate: {test_base['wr']:.1f}% → {test_combo['wr']:.1f}%") emit() if test_combo["total_r"] > 0: emit(" ✅ Filtered subset is profitable on the held-out test set.") emit(" → Phase 2 (geometry grid) may be unnecessary; this filter alone may suffice.") elif test_combo["avg_r"] > test_base["avg_r"]: emit(" ⚠️ Filtered subset improves but is still negative.") emit(" → Phase 2 (geometry grid) needed to find profitable structure.") else: emit(" ❌ Filters do not survive the train/test split — rejected as noise.") emit(" → Skip Phase 1's filter list; rely on Phase 2 (geometry) instead.") Path(args.out).parent.mkdir(parents=True, exist_ok=True) Path(args.out).write_text("\n".join(out_lines)) emit() emit(f"📁 Saved: {args.out}") return 0 if __name__ == "__main__": sys.exit(main())