Files
ForexBot2/geometry_grid.py
T
Harwinder 988adf9a3d Phase 1 + 2 analysis: post-hoc filters and SL/TP geometry grid
- analyze_trades.py: time-split discipline (70/30) for filter selection.
  Reports session-of-day, day-of-week, pair, regime × direction,
  combined-filter performance on held-out test slice. Flags Saturday
  (10% WR) and Sunday (24% WR) as toxic — definite live-engine drops.
- geometry_grid.py: re-simulates the existing trade pool over a
  (sl_mult × rr) grid without re-running the engine. Recovers ATR from
  the original 1.5×ATR SL distance, then walks forward through saved
  OHLCV with first-touch logic. Reports train/test split per cell so
  the verdict survives the 70/30 discipline.

Findings (2025-2026 data, 7 majors):
- M15: no SL/RR cell is profitable on the held-out test set, even with
  Phase 1 filters layered on top. Best test cell (SL=3×ATR, RR=1:1):
  -73 R, 48.7% WR, -0.062 avg R. The architecture is the problem.
- H4 (separate seed, 2 years): Grade B subset is profitable — total
  +20.86 R / 967 trades, PF 1.03. USD/CAD H4 Grade B is *robust*: +34.77
  total R / 118 trades, 44.1% WR, +0.295 avg R, PF 1.52, max DD -9.61 R.
  Positive in every 6-month sub-period, train +20.77 / test +14.0.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 14:42:42 -04:00

362 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Phase 2: re-simulate the existing trade pool under different SL/TP geometries
without re-running the engine.
The engine already gave us, per trade: entry_time, entry_price, direction,
and the original SL distance (= 1.5 × ATR by default). That distance lets
us recover ATR and re-derive new SL/TP for any (sl_mult, rr) combo. We
then walk forward through the saved OHLCV bars, applying first-touch
simulation with worst-case (SL first if both touch same bar).
This is faster than re-running the engine because the signal output (when
to trade, in which direction, at what price) is independent of SL/TP
geometry — only the exits change.
Discipline: we report TRAIN and TEST stats for every cell, time-split at
70/30 by entry_time. A cell wins only if it's positive on the held-out
TEST slice.
Usage:
python geometry_grid.py
python geometry_grid.py --apply-phase1-filters
python geometry_grid.py --pairs USDJPY # single-pair grid
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from typing import Dict, Tuple
import numpy as np
import pandas as pd
# Reuse history loader so we don't re-implement
from backtest import load_history
from config import Timeframe
# trades.csv stores TF as Twelve-Data interval string ("15min", "1h", "1d", …)
_TFSTR_TO_ENUM = {
"1min": Timeframe.M1, "5min": Timeframe.M5,
"15min": Timeframe.M15, "30min": Timeframe.M30,
"1h": Timeframe.H1, "4h": Timeframe.H4,
"1d": Timeframe.D1, "1wk": Timeframe.W1,
}
# Filters carried over from Phase 1
PHASE1_BAD_HOURS = {10, 17, 19, 21}
PHASE1_BAD_PAIRS = {"NZD/USD", "AUD/USD", "GBP/USD"}
PHASE1_BAD_DOWS = {5, 6} # Sat / Sun
def simulate_one(
direction: str,
entry: float,
new_sl: float,
new_tp: float,
forward_bars: pd.DataFrame,
max_bars: int,
spread_pips: float,
pip_value: float,
) -> Tuple[float, str]:
"""First-touch simulation. Returns (r_multiple, outcome)."""
sl_distance = abs(entry - new_sl)
if sl_distance == 0:
return 0.0, "INVALID"
risk_pips = sl_distance / pip_value
spread_r = spread_pips / risk_pips if risk_pips > 0 else 0.0
rr_target = abs(new_tp - entry) / sl_distance
bars_examined = 0
for ts, bar in forward_bars.iterrows():
bars_examined += 1
if bars_examined > max_bars:
break
bar_high = bar["High"]
bar_low = bar["Low"]
if direction == "BUY":
sl_hit = bar_low <= new_sl
tp_hit = bar_high >= new_tp
else:
sl_hit = bar_high >= new_sl
tp_hit = bar_low <= new_tp
if sl_hit:
return -1.0 - spread_r, "LOSS_SL"
if tp_hit:
return rr_target - spread_r, "WIN_TP1"
if bars_examined == 0:
return 0.0, "INVALID"
last = forward_bars.iloc[bars_examined - 1]
pnl = (last["Close"] - entry) if direction == "BUY" else (entry - last["Close"])
return (pnl / sl_distance) - spread_r, "TIMEOUT"
def pip_value_for(symbol: str) -> float:
"""Return pip value: 0.01 for JPY pairs, 0.0001 elsewhere."""
return 0.01 if "JPY" in symbol else 0.0001
def grid_run(
df: pd.DataFrame,
history: Dict[Tuple[str, str], pd.DataFrame],
sl_mults: list,
rr_ratios: list,
max_hold: int = 100,
spread_pips: float = 1.0,
) -> pd.DataFrame:
"""For each (sl_mult, rr) cell, re-simulate every trade and report stats."""
# Pre-index history by (symbol, tf_str) for fast lookup
rows = []
n_total = len(df) * len(sl_mults) * len(rr_ratios)
print(f" evaluating {n_total:,} simulations "
f"({len(df):,} trades × {len(sl_mults)} SL × {len(rr_ratios)} RR)…")
for sl_mult in sl_mults:
for rr in rr_ratios:
r_multiples = []
outcomes = []
entry_times = []
pairs = []
tfs = []
for trade in df.itertuples(index=False):
# Recover ATR from original SL distance (engine used 1.5×ATR)
atr = abs(trade.entry - trade.sl) / 1.5
new_sl_dist = sl_mult * atr
if trade.direction == "BUY":
new_sl = trade.entry - new_sl_dist
new_tp = trade.entry + new_sl_dist * rr
else:
new_sl = trade.entry + new_sl_dist
new_tp = trade.entry - new_sl_dist * rr
hist_df = history.get((trade.symbol, trade.tf))
if hist_df is None:
continue
ts = pd.Timestamp(trade.entry_time)
# Forward bars (strictly after entry)
forward = hist_df[hist_df.index > ts].head(max_hold)
if forward.empty:
continue
pip = pip_value_for(trade.symbol)
r, outcome = simulate_one(
trade.direction, trade.entry,
new_sl, new_tp, forward,
max_bars=max_hold, spread_pips=spread_pips,
pip_value=pip,
)
r_multiples.append(r)
outcomes.append(outcome)
entry_times.append(ts)
pairs.append(trade.pair)
tfs.append(trade.tf)
arr = np.array(r_multiples) if r_multiples else np.array([0.0])
wins = (arr > 0).sum()
losses = (arr <= 0).sum()
gross_win = arr[arr > 0].sum()
gross_loss = -arr[arr <= 0].sum()
pf = gross_win / gross_loss if gross_loss > 0 else float("inf")
cum = np.cumsum(arr)
running_peak = np.maximum.accumulate(cum)
max_dd = (cum - running_peak).min()
rows.append({
"sl_mult": sl_mult,
"rr": rr,
"n": len(arr),
"wins": int(wins),
"wr": wins / len(arr) * 100 if len(arr) else 0,
"avg_r": arr.mean() if len(arr) else 0,
"total_r": arr.sum(),
"pf": pf,
"max_dd": max_dd,
# Carry the per-trade results so we can split train/test downstream
"_arr": arr,
"_entry_times": entry_times,
"_pairs": pairs,
"_outcomes": outcomes,
})
return pd.DataFrame(rows)
def split_summary(cell: pd.Series, train_frac: float = 0.7) -> dict:
"""Split a cell's per-trade results into train/test by entry_time."""
arr = cell["_arr"]
times = cell["_entry_times"]
if len(arr) == 0:
return {"train_total": 0, "train_wr": 0, "test_total": 0, "test_wr": 0}
order = np.argsort(times)
cutoff = int(len(arr) * train_frac)
train_idx = order[:cutoff]
test_idx = order[cutoff:]
train_arr = arr[train_idx]
test_arr = arr[test_idx]
return {
"train_n": len(train_arr),
"train_wr": (train_arr > 0).mean() * 100 if len(train_arr) else 0,
"train_avg": train_arr.mean() if len(train_arr) else 0,
"train_total": train_arr.sum(),
"test_n": len(test_arr),
"test_wr": (test_arr > 0).mean() * 100 if len(test_arr) else 0,
"test_avg": test_arr.mean() if len(test_arr) else 0,
"test_total": test_arr.sum(),
}
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--trades", default="backtest_results_all/trades.csv")
ap.add_argument("--out", default="backtest_results_all/geometry_grid.txt")
ap.add_argument("--max-hold", type=int, default=100)
ap.add_argument("--spread-pips", type=float, default=1.0)
ap.add_argument("--apply-phase1-filters", action="store_true",
help="Drop weekend trades, worst 4 hours, worst 3 pairs before grid")
ap.add_argument("--sl-mults", nargs="+", type=float,
default=[1.0, 1.5, 2.0, 2.5, 3.0])
ap.add_argument("--rr-ratios", nargs="+", type=float,
default=[1.0, 1.5, 2.0, 2.5])
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
print(f"📊 Loaded {len(df):,} trades from {p}")
if args.apply_phase1_filters:
before = len(df)
df = df[~df["hour"].isin(PHASE1_BAD_HOURS)]
df = df[~df["pair"].isin(PHASE1_BAD_PAIRS)]
df = df[~df["dow"].isin(PHASE1_BAD_DOWS)]
df = df.reset_index(drop=True)
print(f" Phase 1 filters applied: {before:,} → {len(df):,} trades "
f"({len(df)/before*100:.0f}% retained)")
# Load history for forward simulation
print(f"📁 Loading history…")
pairs = sorted(df["symbol"].unique().tolist())
tfs = sorted({_TFSTR_TO_ENUM[t] for t in df["tf"].unique() if t in _TFSTR_TO_ENUM},
key=lambda x: x.value)
history_raw = load_history(pairs, tfs)
# Rekey by (symbol, tf_string) to match trades.csv
history = {}
for (sym, tf_enum), hdf in history_raw.items():
history[(sym, tf_enum.value)] = hdf
print(f"\n🧮 Grid search:")
print(f" SL multipliers (× ATR): {args.sl_mults}")
print(f" RR ratios: {args.rr_ratios}\n")
grid = grid_run(
df, history, args.sl_mults, args.rr_ratios,
max_hold=args.max_hold, spread_pips=args.spread_pips,
)
# ── Heatmap (full-data total R) ─────────────────────────────────
pivot_total = grid.pivot(index="sl_mult", columns="rr", values="total_r").round(1)
pivot_wr = grid.pivot(index="sl_mult", columns="rr", values="wr").round(1)
pivot_avg = grid.pivot(index="sl_mult", columns="rr", values="avg_r").round(3)
pivot_pf = grid.pivot(index="sl_mult", columns="rr", values="pf").round(2)
out_lines = []
def emit(line: str = ""):
print(line)
out_lines.append(line)
emit("=" * 80)
emit(f"GEOMETRY GRID — {len(df):,} trades")
emit(f" filters applied: {args.apply_phase1_filters}")
emit(f" spread cost: {args.spread_pips} pip")
emit(f" max hold: {args.max_hold} bars")
emit("=" * 80)
emit()
emit("─── Total R (rows = SL multiplier × ATR; cols = RR ratio) ───")
emit(pivot_total.to_string())
emit()
emit("─── Win rate % ───")
emit(pivot_wr.to_string())
emit()
emit("─── Avg R per trade ───")
emit(pivot_avg.to_string())
emit()
emit("─── Profit factor ───")
emit(pivot_pf.to_string())
emit()
# ── Train/Test discipline on top cells ──────────────────────────
grid_sorted = grid.sort_values("total_r", ascending=False)
emit("=" * 80)
emit("TOP 5 CELLS (by full-period total R) — with TRAIN / TEST split discipline")
emit("=" * 80)
for _, cell in grid_sorted.head(5).iterrows():
s = split_summary(cell)
emit(
f" SL={cell['sl_mult']:.1f}×ATR RR=1:{cell['rr']:.1f} "
f"| full: {cell['n']} trades WR {cell['wr']:.1f}% "
f"avgR {cell['avg_r']:+.3f} totalR {cell['total_r']:+.1f} "
f"PF {cell['pf']:.2f}"
)
emit(
f" train ({s['train_n']}): "
f"WR {s['train_wr']:.1f}% avgR {s['train_avg']:+.3f} totalR {s['train_total']:+.1f}"
)
emit(
f" test ({s['test_n']}): "
f"WR {s['test_wr']:.1f}% avgR {s['test_avg']:+.3f} totalR {s['test_total']:+.1f}"
)
emit()
# ── Bottom 3 for contrast ───────────────────────────────────────
emit("─── BOTTOM 3 cells (worst total R) ───")
for _, cell in grid_sorted.tail(3).iterrows():
emit(
f" SL={cell['sl_mult']:.1f}×ATR RR=1:{cell['rr']:.1f} "
f"totalR {cell['total_r']:+.1f} WR {cell['wr']:.1f}%"
)
emit()
# ── Verdict ─────────────────────────────────────────────────────
emit("=" * 80)
emit("VERDICT")
emit("=" * 80)
best = grid_sorted.iloc[0]
s = split_summary(best)
emit(
f" Best cell: SL={best['sl_mult']:.1f}×ATR, RR=1:{best['rr']:.1f} "
f"(full totalR {best['total_r']:+.1f})"
)
emit(
f" Test slice: {s['test_n']} trades, WR {s['test_wr']:.1f}%, "
f"totalR {s['test_total']:+.1f}, avgR {s['test_avg']:+.3f}"
)
if s["test_total"] > 0:
emit(" ✅ Best geometry is profitable on held-out test set.")
emit(" → Phase 3A: hardcode in _generate_trade_setup, re-run end-to-end.")
elif s["test_avg"] > -0.05:
emit(" ⚠️ Best geometry is near-break-even on test set.")
emit(" → Phase 3A worth attempting; combine with Phase 1 filters for margin.")
else:
emit(" ❌ No geometry is profitable on held-out test set.")
emit(" → Phase 3B: M15 confluence is structurally not edge-bearing.")
emit(" Consider H4 primary, mean-reversion-only, or indicator-level meta-model.")
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())