Add web dashboard (Redis + FastAPI + WebSocket), scope XAU penalties

Signal daemon (signal_web.py): candle-boundary scanner mirroring
telegram_bot.py, publishes serialized SignalReport JSON to Redis.
Web API (web/app.py): thin FastAPI reader behind Apache, serves
/api/signals REST + /ws WebSocket from Redis pub/sub.
Frontend (web/static/index.html): 3-pair grid, color-coded signals,
indicator breakdown per timeframe, trade setup display.

Also: XAU-specific penalty scoping in signal_aggregator (softer
confidence penalties for gold, original strict values for forex),
pair-aware session filter routing XAU to its own blocked hours.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
harwinder
2026-05-14 23:15:37 -04:00
parent 57769967d1
commit 98daf71a52
10 changed files with 1401 additions and 883 deletions
-618
View File
@@ -1,618 +0,0 @@
#!/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,
max_hours: int = 0,
) -> 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])
time_limit = max_hours * 12 if max_hours > 0 else max_bars
effective_max = min(time_limit, max_bars)
end = min(start + effective_max, 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)")
parser.add_argument("--max-hours", type=int, default=0,
help="Max hours to hold a trade before time-exit (0 = disabled)")
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, max_hours=args.max_hours)
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()