1e5047e20f
- Drop USD/JPY from scan pairs (zero signals across all timeframes) - Disable divergence indicator (zero impact in 14-config lab test) - Parallelize evaluate.py with ProcessPoolExecutor (16 cores) - Add --pair CLI filter to evaluate.py for single-pair backtests - Add data freshness guard: stale cache auto-invalidated, primary TF stale data suppresses signals to prevent trading on old data - Add /cache command: shows fetch age and last bar per pair/TF - Add /clearcache command: wipes in-memory cache for fresh API fetch - Remove BB from WebSocket breach alerts (keep structural levels only) - Show live prices only for WS-monitored pairs (EUR/USD, XAU/USD) - Replace arrow emojis with text tags in indicator lines Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
614 lines
25 KiB
Python
614 lines
25 KiB
Python
#!/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()
|