#!/usr/bin/env python3 """ ForexBot2 — Historical Signal Evaluation ========================================= Walks the engine over the last 7 days of market data for all scan pairs, records every signal and trade setup, then checks outcomes on M5 data. GPU-accelerated outcome evaluation via CuPy (RTX 4070 SUPER). Falls back to NumPy if CuPy/CUDA is unavailable. Usage: python backtest/evaluate.py # full 7-day eval, M15+H1 python backtest/evaluate.py --tf H1 # single timeframe python backtest/evaluate.py --days 3 # shorter window python backtest/evaluate.py --no-cache # re-download all data """ import argparse import csv import os import sys import time from concurrent.futures import ProcessPoolExecutor, as_completed from dataclasses import dataclass, field from datetime import datetime, timezone, timedelta from pathlib import Path from typing import Dict, List, Optional, Tuple import numpy as np import pandas as pd sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from config import ( PlatformConfig, Timeframe, SignalStrength, EURUSD, USDJPY, GBPUSD, XAUUSD, PAIR_INFO, ) from data_provider import ForexDataProvider from signal_aggregator import SignalAggregator # ── GPU setup ──────────────────────────────────────────────────────── try: if "CUDA_PATH" not in os.environ: _nvidia_dir = os.path.join( sys.prefix, "lib", "python3.12", "site-packages", "nvidia" ) if os.path.isdir(_nvidia_dir): os.environ["CUDA_PATH"] = _nvidia_dir import cupy as cp _test = cp.array([1.0, 2.0]) _ = cp.where(_test > 0.5) GPU = True GPU_NAME = cp.cuda.runtime.getDeviceProperties(0)["name"].decode() print(f"GPU: {GPU_NAME} (CuPy {cp.__version__})") del _test except Exception: import numpy as cp # type: ignore[no-redef] GPU = False print("GPU: not available — falling back to NumPy") # ── Constants ──────────────────────────────────────────────────────── BACKTEST_DIR = Path(__file__).resolve().parent DATA_DIR = BACKTEST_DIR / "data" RESULTS_DIR = BACKTEST_DIR / "results" SCAN_PAIRS = [EURUSD, GBPUSD, XAUUSD] EVAL_TIMEFRAMES = [Timeframe.M15, Timeframe.H1] TF_CONFIRMATIONS = { Timeframe.M5: [Timeframe.M15, Timeframe.H1], Timeframe.M15: [Timeframe.M5, Timeframe.H1], Timeframe.H1: [Timeframe.M15, Timeframe.H4], Timeframe.H4: [Timeframe.H1, Timeframe.D1], } ALL_TFS_NEEDED = { Timeframe.M5, Timeframe.M15, Timeframe.H1, Timeframe.H4, Timeframe.D1, } OUTCOME_TF = Timeframe.M5 MIN_LOOKBACK_BARS = 250 @dataclass class Trade: pair: str display: str timeframe: str entry_time: pd.Timestamp direction: str entry_price: float stop_loss: float take_profit_1: float take_profit_2: Optional[float] risk_reward: float risk_pips: float reward_pips: float signal_quality: str confidence: float score: float regime: str outcome: str = "OPEN" exit_price: float = 0.0 exit_time: Optional[pd.Timestamp] = None pnl_pips: float = 0.0 bars_held: int = 0 # ══════════════════════════════════════════════════════════════════════ # DATA FETCHING & CACHING # ══════════════════════════════════════════════════════════════════════ def _cache_path(symbol: str, tf: Timeframe) -> Path: safe = symbol.replace("/", "_").replace("=", "") return DATA_DIR / f"{safe}_{tf.value}.parquet" def fetch_all_data( pairs: List[str], timeframes, lookback_days: int = 67, use_cache: bool = True, ) -> Dict[str, Dict[Timeframe, pd.DataFrame]]: """Fetch OHLCV for all pair×TF combos, cache to parquet.""" config = PlatformConfig() config.data_lookback_days = lookback_days dp = ForexDataProvider(config) data: Dict[str, Dict[Timeframe, pd.DataFrame]] = {} total = len(pairs) * len(timeframes) done = 0 for symbol in pairs: data[symbol] = {} display = PAIR_INFO.get(symbol, {}).get("display", symbol) for tf in timeframes: done += 1 cache = _cache_path(symbol, tf) if use_cache and cache.exists(): mtime = datetime.fromtimestamp(cache.stat().st_mtime, tz=timezone.utc) age_hours = (datetime.now(timezone.utc) - mtime).total_seconds() / 3600 if age_hours < 4: df = pd.read_parquet(cache) data[symbol][tf] = df print(f" [{done}/{total}] {display} {tf.value} — cached ({len(df)} bars)") continue print(f" [{done}/{total}] {display} {tf.value} — fetching...", end="", flush=True) try: df = dp.fetch_ohlcv(symbol, tf, lookback_days=lookback_days) data[symbol][tf] = df try: df.to_parquet(cache) except Exception: pass print(f" {len(df)} bars") except Exception as e: print(f" FAILED: {e}") return data # ══════════════════════════════════════════════════════════════════════ # WALK-FORWARD EVALUATION # ══════════════════════════════════════════════════════════════════════ def walk_forward( pair: str, eval_tf: Timeframe, all_data: Dict[Timeframe, pd.DataFrame], eval_days: int, ) -> List[Trade]: """Walk through eval_days of candles, run the engine at each bar.""" primary_df = all_data.get(eval_tf) if primary_df is None or primary_df.empty: return [] conf_tfs = TF_CONFIRMATIONS.get(eval_tf, [Timeframe.M15, Timeframe.H4]) display = PAIR_INFO.get(pair, {}).get("display", pair) cutoff = primary_df.index[-1] - pd.Timedelta(days=eval_days) eval_indices = primary_df.index[primary_df.index >= cutoff] if len(eval_indices) == 0: return [] first_eval_pos = primary_df.index.get_loc(eval_indices[0]) if first_eval_pos < MIN_LOOKBACK_BARS: first_eval_pos = MIN_LOOKBACK_BARS eval_indices = primary_df.index[first_eval_pos:] config = PlatformConfig() config.primary_timeframe = eval_tf config.confirmation_timeframes = conf_tfs aggregator = SignalAggregator(config) cooldown_bars = { Timeframe.M5: 24, Timeframe.M15: 10, Timeframe.H1: 5, Timeframe.H4: 3, }.get(eval_tf, 6) trades: List[Trade] = [] total = len(eval_indices) bars_until_next = 0 for i, eval_time in enumerate(eval_indices): if (i + 1) % max(1, total // 10) == 0: print(f" {display} {eval_tf.value}: {i+1}/{total} candles...", flush=True) if bars_until_next > 0: bars_until_next -= 1 continue dfs: Dict[Timeframe, pd.DataFrame] = {} dfs[eval_tf] = primary_df.loc[:eval_time] for ctf in conf_tfs: ctf_df = all_data.get(ctf) if ctf_df is not None and not ctf_df.empty: dfs[ctf] = ctf_df.loc[:eval_time] try: report = aggregator.analyze_from_dataframes( symbol=pair, dfs=dfs, use_news=False, ) except Exception: continue if report.trade_setup and report.signal_quality in ("A+", "A", "B", "C"): ts = report.trade_setup trades.append(Trade( pair=pair, display=display, timeframe=eval_tf.value, entry_time=eval_time, direction=ts.direction, entry_price=ts.entry_price, stop_loss=ts.stop_loss, take_profit_1=ts.take_profit_1, take_profit_2=ts.take_profit_2, risk_reward=ts.risk_reward, risk_pips=ts.risk_pips, reward_pips=ts.reward_pips, signal_quality=report.signal_quality, confidence=report.final_confidence, score=report.final_score, regime=report.market_regime, )) bars_until_next = cooldown_bars return trades def _walk_forward_worker(args): """Top-level worker for multiprocessing (must be picklable).""" pair, tf, pair_data, eval_days = args display = PAIR_INFO.get(pair, {}).get("display", pair) trades = walk_forward(pair, tf, pair_data, eval_days) return pair, tf, display, trades # ══════════════════════════════════════════════════════════════════════ # GPU-ACCELERATED OUTCOME EVALUATION # ══════════════════════════════════════════════════════════════════════ def evaluate_outcomes( trades: List[Trade], m5_data: Dict[str, pd.DataFrame], max_bars: int = 500, ) -> List[Trade]: """ For each trade, scan forward in M5 data to check if TP1 or SL hit first. Uses GPU (CuPy) for vectorized parallel evaluation across all trades per pair. """ if not trades: return trades pairs_in_trades = set(t.pair for t in trades) label = "GPU" if GPU else "CPU" print(f"\nEvaluating {len(trades)} trade outcomes on {label}...") for pair in pairs_in_trades: df = m5_data.get(pair) if df is None or df.empty: continue pair_trades = [t for t in trades if t.pair == pair] if not pair_trades: continue highs_np = df["High"].values.astype(np.float64) lows_np = df["Low"].values.astype(np.float64) closes_np = df["Close"].values.astype(np.float64) timestamps = df.index highs_gpu = cp.asarray(highs_np) lows_gpu = cp.asarray(lows_np) for trade in pair_trades: start_mask = timestamps > trade.entry_time start_indices = np.where(start_mask)[0] if len(start_indices) == 0: continue start = int(start_indices[0]) end = min(start + max_bars, len(highs_gpu)) if start >= end: continue h_slice = highs_gpu[start:end] l_slice = lows_gpu[start:end] if trade.direction == "BUY": tp_mask = h_slice >= trade.take_profit_1 sl_mask = l_slice <= trade.stop_loss else: tp_mask = l_slice <= trade.take_profit_1 sl_mask = h_slice >= trade.stop_loss tp_hits = cp.where(tp_mask)[0] sl_hits = cp.where(sl_mask)[0] tp_idx = int(tp_hits[0]) if len(tp_hits) > 0 else max_bars + 1 sl_idx = int(sl_hits[0]) if len(sl_hits) > 0 else max_bars + 1 pip_val = PAIR_INFO.get(pair, {}).get("pip_value", 0.0001) if tp_idx <= sl_idx and tp_idx < max_bars + 1: trade.outcome = "WIN" trade.bars_held = tp_idx + 1 real_idx = start + tp_idx trade.exit_price = trade.take_profit_1 trade.exit_time = timestamps[real_idx] trade.pnl_pips = trade.reward_pips elif sl_idx < tp_idx and sl_idx < max_bars + 1: trade.outcome = "LOSS" trade.bars_held = sl_idx + 1 real_idx = start + sl_idx trade.exit_price = trade.stop_loss trade.exit_time = timestamps[real_idx] trade.pnl_pips = -trade.risk_pips else: trade.outcome = "OPEN" last_idx = end - 1 trade.exit_time = timestamps[last_idx] exit_p = float(closes_np[last_idx]) trade.exit_price = exit_p if trade.direction == "BUY": trade.pnl_pips = (exit_p - trade.entry_price) / pip_val else: trade.pnl_pips = (trade.entry_price - exit_p) / pip_val trade.bars_held = end - start return trades # ══════════════════════════════════════════════════════════════════════ # REPORTING # ══════════════════════════════════════════════════════════════════════ def print_report(trades: List[Trade], eval_days: int): print("\n") print("=" * 90) print(f" FOREXBOT2 — ENGINE EVALUATION REPORT ({eval_days}-DAY)") print(f" {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}") print("=" * 90) if not trades: print("\n No trades generated.\n") return closed = [t for t in trades if t.outcome in ("WIN", "LOSS")] wins = [t for t in closed if t.outcome == "WIN"] losses = [t for t in closed if t.outcome == "LOSS"] still_open = [t for t in trades if t.outcome == "OPEN"] # ── Overall ────────────────────────────────────────────────── print(f"\n OVERALL") print(f" {'─' * 50}") print(f" Total signals with trade setup: {len(trades)}") print(f" Closed: {len(closed)} | Still open: {len(still_open)}") if closed: win_rate = len(wins) / len(closed) * 100 total_pnl = sum(t.pnl_pips for t in closed) avg_win = np.mean([t.pnl_pips for t in wins]) if wins else 0 avg_loss = np.mean([abs(t.pnl_pips) for t in losses]) if losses else 0 gross_profit = sum(t.pnl_pips for t in wins) if wins else 0 gross_loss = sum(abs(t.pnl_pips) for t in losses) if losses else 0 profit_factor = gross_profit / gross_loss if gross_loss > 0 else float("inf") avg_rr = np.mean([t.risk_reward for t in closed]) avg_bars = np.mean([t.bars_held for t in closed]) print(f" Win rate: {win_rate:.1f}% ({len(wins)}W / {len(losses)}L)") print(f" Profit factor: {profit_factor:.2f}") print(f" Total P&L: {total_pnl:+.1f} pips") print(f" Avg win: +{avg_win:.1f} pips | Avg loss: -{avg_loss:.1f} pips") print(f" Avg R:R setup: 1:{avg_rr:.1f}") print(f" Avg hold: {avg_bars:.0f} M5 bars ({avg_bars * 5:.0f} min)") # Consecutive streaks max_wins = max_losses = cur_wins = cur_losses = 0 for t in closed: if t.outcome == "WIN": cur_wins += 1 cur_losses = 0 max_wins = max(max_wins, cur_wins) else: cur_losses += 1 cur_wins = 0 max_losses = max(max_losses, cur_losses) print(f" Max streak: {max_wins}W / {max_losses}L") # ── By pair ────────────────────────────────────────────────── print(f"\n BY PAIR") print(f" {'─' * 50}") print(f" {'Pair':<12} {'Trades':>7} {'Closed':>7} {'Win%':>6} {'P&L':>10} {'PF':>7}") for pair in SCAN_PAIRS: display = PAIR_INFO.get(pair, {}).get("display", pair) pt = [t for t in trades if t.pair == pair] pc = [t for t in pt if t.outcome in ("WIN", "LOSS")] pw = [t for t in pc if t.outcome == "WIN"] if not pt: continue wr = (len(pw) / len(pc) * 100) if pc else 0 pnl = sum(t.pnl_pips for t in pc) gp = sum(t.pnl_pips for t in pw) if pw else 0 gl = sum(abs(t.pnl_pips) for t in pc if t.outcome == "LOSS") or 1 pf = gp / gl if gl > 0 else 0 print(f" {display:<12} {len(pt):>7} {len(pc):>7} {wr:>5.1f}% {pnl:>+9.1f}p {pf:>6.2f}") # ── By timeframe ───────────────────────────────────────────── print(f"\n BY TIMEFRAME") print(f" {'─' * 50}") print(f" {'TF':<8} {'Trades':>7} {'Closed':>7} {'Win%':>6} {'P&L':>10} {'PF':>7}") tfs = sorted(set(t.timeframe for t in trades)) for tf in tfs: tt = [t for t in trades if t.timeframe == tf] tc = [t for t in tt if t.outcome in ("WIN", "LOSS")] tw = [t for t in tc if t.outcome == "WIN"] if not tt: continue wr = (len(tw) / len(tc) * 100) if tc else 0 pnl = sum(t.pnl_pips for t in tc) gp = sum(t.pnl_pips for t in tw) if tw else 0 gl = sum(abs(t.pnl_pips) for t in tc if t.outcome == "LOSS") or 1 pf = gp / gl if gl > 0 else 0 print(f" {tf:<8} {len(tt):>7} {len(tc):>7} {wr:>5.1f}% {pnl:>+9.1f}p {pf:>6.2f}") # ── By quality grade ───────────────────────────────────────── print(f"\n BY QUALITY GRADE") print(f" {'─' * 50}") print(f" {'Grade':<8} {'Trades':>7} {'Closed':>7} {'Win%':>6} {'P&L':>10} {'Avg RR':>7}") for grade in ("A+", "A", "B", "C"): gt = [t for t in trades if t.signal_quality == grade] gc = [t for t in gt if t.outcome in ("WIN", "LOSS")] gw = [t for t in gc if t.outcome == "WIN"] if not gt: continue wr = (len(gw) / len(gc) * 100) if gc else 0 pnl = sum(t.pnl_pips for t in gc) arr = np.mean([t.risk_reward for t in gt]) print(f" {grade:<8} {len(gt):>7} {len(gc):>7} {wr:>5.1f}% {pnl:>+9.1f}p {arr:>6.1f}") # ── By regime ──────────────────────────────────────────────── print(f"\n BY MARKET REGIME") print(f" {'─' * 50}") print(f" {'Regime':<16} {'Trades':>7} {'Win%':>6} {'P&L':>10}") for regime in sorted(set(t.regime for t in trades)): rt = [t for t in trades if t.regime == regime] rc = [t for t in rt if t.outcome in ("WIN", "LOSS")] rw = [t for t in rc if t.outcome == "WIN"] wr = (len(rw) / len(rc) * 100) if rc else 0 pnl = sum(t.pnl_pips for t in rc) label = regime.replace("_", " ").title() print(f" {label:<16} {len(rt):>7} {wr:>5.1f}% {pnl:>+9.1f}p") # ── Direction ──────────────────────────────────────────────── print(f"\n BY DIRECTION") print(f" {'─' * 50}") for d in ("BUY", "SELL"): dt = [t for t in trades if t.direction == d] dc = [t for t in dt if t.outcome in ("WIN", "LOSS")] dw = [t for t in dc if t.outcome == "WIN"] wr = (len(dw) / len(dc) * 100) if dc else 0 pnl = sum(t.pnl_pips for t in dc) print(f" {d:<8} {len(dt):>7} trades {wr:>5.1f}% win {pnl:>+9.1f}p") print(f"\n{'=' * 90}\n") def save_trades_csv(trades: List[Trade], filename: str): path = RESULTS_DIR / filename with open(path, "w", newline="") as f: writer = csv.writer(f) writer.writerow([ "pair", "display", "timeframe", "entry_time", "direction", "entry_price", "stop_loss", "take_profit_1", "risk_reward", "risk_pips", "reward_pips", "signal_quality", "confidence", "score", "regime", "outcome", "exit_price", "exit_time", "pnl_pips", "bars_held", ]) for t in trades: writer.writerow([ t.pair, t.display, t.timeframe, t.entry_time, t.direction, f"{t.entry_price:.5f}", f"{t.stop_loss:.5f}", f"{t.take_profit_1:.5f}", f"{t.risk_reward:.2f}", f"{t.risk_pips:.1f}", f"{t.reward_pips:.1f}", t.signal_quality, f"{t.confidence:.1f}", f"{t.score:.1f}", t.regime, t.outcome, f"{t.exit_price:.5f}", t.exit_time or "", f"{t.pnl_pips:.1f}", t.bars_held, ]) print(f"Trades saved to {path}") # ══════════════════════════════════════════════════════════════════════ # MAIN # ══════════════════════════════════════════════════════════════════════ def main(): parser = argparse.ArgumentParser(description="ForexBot2 signal engine evaluator") parser.add_argument("--days", type=int, default=7, help="Evaluation window in days (default: 7)") parser.add_argument("--tf", default=None, help="Single timeframe to evaluate (M15, H1)") parser.add_argument("--no-cache", action="store_true", help="Re-download all data") parser.add_argument("--max-outcome-bars", type=int, default=1000, help="Max M5 bars to scan for SL/TP hit (default: 1000 = ~83h)") parser.add_argument("--pair", default=None, help="Single pair to evaluate (EURUSD, USDJPY, GBPUSD, XAUUSD)") args = parser.parse_args() eval_tfs = EVAL_TIMEFRAMES if args.tf: tf_map = {"M5": Timeframe.M5, "M15": Timeframe.M15, "H1": Timeframe.H1, "H4": Timeframe.H4} if args.tf.upper() not in tf_map: sys.exit(f"Invalid timeframe: {args.tf}. Choose from: {list(tf_map.keys())}") eval_tfs = [tf_map[args.tf.upper()]] pair_map = {"EURUSD": EURUSD, "USDJPY": USDJPY, "GBPUSD": GBPUSD, "XAUUSD": XAUUSD} scan_pairs = SCAN_PAIRS if args.pair: key = args.pair.upper().replace("/", "") if key not in pair_map: sys.exit(f"Invalid pair: {args.pair}. Choose from: {list(pair_map.keys())}") scan_pairs = [pair_map[key]] DATA_DIR.mkdir(parents=True, exist_ok=True) RESULTS_DIR.mkdir(parents=True, exist_ok=True) # ── 1. Fetch data ──────────────────────────────────────────── lookback = args.days + 60 print(f"\n{'=' * 60}") print(f" Fetching {lookback} days of data for {len(scan_pairs)} pair(s)...") print(f"{'=' * 60}") t0 = time.time() all_data = fetch_all_data( scan_pairs, ALL_TFS_NEEDED, lookback_days=lookback, use_cache=not args.no_cache, ) print(f" Data fetched in {time.time() - t0:.1f}s") # ── 2. Walk-forward evaluation (multi-core) ────────────────── print(f"\n{'=' * 60}") n_workers = min(os.cpu_count() or 4, 16) print(f" Walk-forward evaluation: {args.days} days, TFs: {[tf.value for tf in eval_tfs]}") print(f" Using {n_workers} CPU cores") print(f"{'=' * 60}") t1 = time.time() jobs = [] for pair in scan_pairs: pair_data = all_data.get(pair, {}) if not pair_data: display = PAIR_INFO.get(pair, {}).get("display", pair) print(f" {display}: no data, skipping") continue for tf in eval_tfs: jobs.append((pair, tf, pair_data, args.days)) all_trades: List[Trade] = [] with ProcessPoolExecutor(max_workers=n_workers) as pool: futures = {pool.submit(_walk_forward_worker, job): job for job in jobs} for future in as_completed(futures): pair, tf, display, trades = future.result() print(f" {display} {tf.value}: {len(trades)} trade setups") all_trades.extend(trades) print(f"\n Walk-forward complete in {time.time() - t1:.1f}s") print(f" Total trade setups: {len(all_trades)}") # ── 3. Outcome evaluation (GPU) ────────────────────────────── m5_data = {pair: all_data.get(pair, {}).get(Timeframe.M5) for pair in scan_pairs} m5_data = {k: v for k, v in m5_data.items() if v is not None} t2 = time.time() all_trades = evaluate_outcomes(all_trades, m5_data, max_bars=args.max_outcome_bars) print(f" Outcomes evaluated in {time.time() - t2:.3f}s") # ── 4. Report ──────────────────────────────────────────────── print_report(all_trades, args.days) ts = datetime.now().strftime("%Y%m%d_%H%M%S") save_trades_csv(all_trades, f"eval_{args.days}d_{ts}.csv") if __name__ == "__main__": main()