Backtest tuning, data freshness guard, Telegram improvements
- 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>
This commit is contained in:
@@ -0,0 +1,613 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user