e57f4a1ecd
Install nvidia pip packages for CUDA 12 runtime DLLs (no full toolkit needed): nvidia-cuda-nvrtc-cu12, nvidia-cuda-runtime-cu12, nvidia-curand-cu12, nvidia-cublas-cu12 Add _register_nvidia_dlls() in backtest_gpu.py that auto-discovers and registers nvidia package DLL directories (site-packages/nvidia/*/bin/) via os.add_dll_directory() before CuPy is imported. Suppresses the spurious "CUDA path could not be detected" warning from cupy._environment. Backend line now reads: CuPy 14.0.1 (RTX GPU) Benchmark: scoring pass on 100k bars — NumPy 1.1ms vs CuPy 0.18ms = 6.3x. Results are identical to CPU path (deterministic).
1322 lines
52 KiB
Python
1322 lines
52 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
ForexBot2 — GPU-Accelerated Backtester (standalone)
|
||
=====================================================
|
||
Computes ALL indicators in ONE vectorised pass over the full price series
|
||
(O(N) total work), then replays positions bar-by-bar with a sequential
|
||
state machine. Completely separate from backtest.py — no shared engine code.
|
||
|
||
Compute backend
|
||
GPU — CuPy (NVIDIA CUDA): pip install cupy-cuda12x
|
||
CPU — NumPy fallback when CuPy is absent (auto-detected)
|
||
|
||
Why O(N) beats O(N²)
|
||
backtest.py slices the DataFrame and runs all indicators fresh each bar
|
||
→ N × O(N) indicator work = O(N²)
|
||
backtest_gpu.py computes every indicator series end-to-end once, then reads
|
||
bar T's value in O(1) → O(N) total
|
||
|
||
Usage
|
||
python backtest_gpu.py full history, H1, no filters
|
||
python backtest_gpu.py --start 2024-01-01 date-windowed
|
||
python backtest_gpu.py --regime-filter ADX gate on H4
|
||
python backtest_gpu.py --session-filter UTC 07-16 gate
|
||
python backtest_gpu.py --tf H4 --journal H4 with trade log
|
||
python backtest_gpu.py --help
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import logging
|
||
import os
|
||
import sys
|
||
from collections import deque
|
||
from typing import Dict, List, Optional, Tuple
|
||
|
||
import numpy as np
|
||
import pandas as pd
|
||
|
||
# Force UTF-8 on Windows
|
||
if hasattr(sys.stdout, "reconfigure"):
|
||
sys.stdout.reconfigure(encoding="utf-8")
|
||
|
||
# ── GPU backend ───────────────────────────────────────────────────
|
||
# Register NVIDIA pip-package DLL directories before importing CuPy.
|
||
# nvidia-cuda-nvrtc-cu12 / nvidia-curand-cu12 etc. drop their DLLs into
|
||
# site-packages/nvidia/<pkg>/bin/ — they are NOT on PATH by default.
|
||
def _register_nvidia_dlls() -> None:
|
||
import pathlib, site, warnings
|
||
for sp in site.getsitepackages():
|
||
nvidia_base = pathlib.Path(sp) / "nvidia"
|
||
if not nvidia_base.is_dir():
|
||
continue
|
||
for sub in nvidia_base.iterdir():
|
||
bin_dir = sub / "bin"
|
||
if bin_dir.is_dir():
|
||
try:
|
||
os.add_dll_directory(str(bin_dir))
|
||
except (OSError, AttributeError):
|
||
pass # add_dll_directory is Windows-only; skip on Linux/Mac
|
||
|
||
_register_nvidia_dlls()
|
||
|
||
try:
|
||
import warnings as _w
|
||
with _w.catch_warnings():
|
||
_w.simplefilter("ignore") # suppress "CUDA path could not be detected"
|
||
import cupy as cp # type: ignore
|
||
_GPU = True
|
||
_BACKEND = f"CuPy {cp.__version__} (RTX GPU)"
|
||
except Exception:
|
||
cp = np # type: ignore[assignment]
|
||
_GPU = False
|
||
_BACKEND = "NumPy (CPU — install cupy-cuda12x + nvidia-cuda-nvrtc-cu12 for GPU)"
|
||
|
||
from config import PlatformConfig, Timeframe, USDJPY, PIP_VALUE
|
||
from data_provider import ForexDataProvider
|
||
from metrics import BacktestResult, Trade, compute_metrics, trades_to_dataframe
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
TF_MINUTES: Dict[Timeframe, int] = {
|
||
Timeframe.M1: 1, Timeframe.M5: 5, Timeframe.M15: 15,
|
||
Timeframe.M30: 30, Timeframe.H1: 60, Timeframe.H4: 240,
|
||
Timeframe.D1: 1440, Timeframe.W1: 10080,
|
||
}
|
||
|
||
GRADE_ORDER = ["A+", "A", "B", "C", "D", "F"]
|
||
|
||
|
||
def _grade_passes(grade: str, min_grade: str) -> bool:
|
||
if grade not in GRADE_ORDER or min_grade not in GRADE_ORDER:
|
||
return False
|
||
return GRADE_ORDER.index(grade) <= GRADE_ORDER.index(min_grade)
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════
|
||
# VECTORISED INDICATOR ENGINE
|
||
# ══════════════════════════════════════════════════════════════════
|
||
|
||
class VectorIndicators:
|
||
"""
|
||
Compute every indicator series across the full OHLCV dataset in one pass.
|
||
Uses pandas ewm/rolling (C-optimised) for EMA/rolling math.
|
||
CuPy is applied to the scoring arrays where element-wise GPU math helps.
|
||
"""
|
||
|
||
def __init__(self, config: PlatformConfig):
|
||
self.tc = config.technical
|
||
|
||
# ── Low-level helpers ─────────────────────────────────────────
|
||
|
||
@staticmethod
|
||
def _ema(s: pd.Series, span: int) -> pd.Series:
|
||
return s.ewm(span=span, adjust=False).mean()
|
||
|
||
@staticmethod
|
||
def _wilder(s: pd.Series, period: int) -> pd.Series:
|
||
return s.ewm(alpha=1.0 / period, min_periods=period).mean()
|
||
|
||
# ── EMA alignment ─────────────────────────────────────────────
|
||
|
||
def ema_alignment(
|
||
self, close: pd.Series
|
||
) -> Tuple[pd.Series, pd.Series, pd.Series]:
|
||
"""
|
||
Returns (norm [-1..+1], confidence, ema200).
|
||
norm > 0.6 → STRONG_BUY, > 0.2 → BUY, < -0.2 → SELL, < -0.6 → STRONG_SELL.
|
||
"""
|
||
tc = self.tc
|
||
f = self._ema(close, tc.ema_fast)
|
||
m = self._ema(close, tc.ema_medium)
|
||
s = self._ema(close, tc.ema_slow)
|
||
t200 = self._ema(close, tc.ema_trend)
|
||
|
||
cv, fv, mv, sv, tv = (
|
||
close.values, f.values, m.values, s.values, t200.values
|
||
)
|
||
raw = (
|
||
np.sign(cv - fv) + np.sign(fv - mv) +
|
||
np.sign(mv - sv) + np.sign(sv - tv)
|
||
) / 4.0 # -1 .. +1
|
||
|
||
conf = np.where(
|
||
np.abs(raw) > 0.6,
|
||
np.minimum(90.0, 60.0 + np.abs(raw) * 30.0),
|
||
np.where(np.abs(raw) > 0.2, 55.0 + np.abs(raw) * 20.0, 40.0),
|
||
)
|
||
return (
|
||
pd.Series(raw, index=close.index),
|
||
pd.Series(conf, index=close.index),
|
||
t200,
|
||
)
|
||
|
||
# ── RSI ───────────────────────────────────────────────────────
|
||
|
||
def rsi(
|
||
self, close: pd.Series
|
||
) -> Tuple[pd.Series, pd.Series, pd.Series]:
|
||
"""Returns (rsi_values, signal [-1..+1], confidence)."""
|
||
tc = self.tc
|
||
delta = close.diff()
|
||
gain = delta.where(delta > 0, 0.0)
|
||
loss = (-delta).where(delta < 0, 0.0)
|
||
avg_g = self._wilder(gain, tc.rsi_period)
|
||
avg_l = self._wilder(loss, tc.rsi_period)
|
||
rs = avg_g / avg_l.replace(0, np.nan)
|
||
rsi_v = (100 - 100 / (1 + rs)).fillna(50.0)
|
||
|
||
v = rsi_v.values
|
||
ob, os_, nu, nl = (
|
||
tc.rsi_overbought, tc.rsi_oversold,
|
||
tc.rsi_neutral_upper, tc.rsi_neutral_lower,
|
||
)
|
||
sig = np.where(v >= ob, -1,
|
||
np.where(v <= os_, 1,
|
||
np.where(v >= nu, 1,
|
||
np.where(v <= nl, -1, 0)))).astype(float)
|
||
|
||
conf = np.where(v >= ob, np.minimum(80.0, 50.0 + (v - ob)),
|
||
np.where(v <= os_, np.minimum(80.0, 50.0 + (os_ - v)),
|
||
np.where(np.abs(v - 50) > 10, 45.0, 30.0)))
|
||
|
||
return rsi_v, pd.Series(sig, index=close.index), pd.Series(conf, index=close.index)
|
||
|
||
# ── MACD ──────────────────────────────────────────────────────
|
||
|
||
def macd(
|
||
self, close: pd.Series
|
||
) -> Tuple[pd.Series, pd.Series, pd.Series]:
|
||
"""Returns (histogram, signal [-2..+2], confidence)."""
|
||
tc = self.tc
|
||
ema_f = self._ema(close, tc.macd_fast)
|
||
ema_s = self._ema(close, tc.macd_slow)
|
||
line = ema_f - ema_s
|
||
sig_l = self._ema(line, tc.macd_signal)
|
||
hist = line - sig_l
|
||
|
||
above = (line.values > sig_l.values)
|
||
prev_ab = np.roll(above, 1); prev_ab[0] = above[0]
|
||
bull_x = above & ~prev_ab
|
||
bear_x = ~above & prev_ab
|
||
hist_v = hist.values
|
||
|
||
sig = np.where(bull_x, 2.0,
|
||
np.where(bear_x, -2.0,
|
||
np.where(hist_v > 0, 1.0,
|
||
np.where(hist_v < 0, -1.0, 0.0))))
|
||
|
||
conf = np.where(bull_x | bear_x, 75.0,
|
||
np.where(np.abs(hist_v) > 0, 55.0, 35.0))
|
||
|
||
return hist, pd.Series(sig, index=close.index), pd.Series(conf, index=close.index)
|
||
|
||
# ── Bollinger Bands ───────────────────────────────────────────
|
||
|
||
def bollinger(
|
||
self, close: pd.Series
|
||
) -> Tuple[pd.Series, pd.Series, pd.Series]:
|
||
"""Returns (bb_position [0..1], signal [-1..+1], confidence)."""
|
||
tc = self.tc
|
||
sma = close.rolling(tc.bb_period).mean()
|
||
std = close.rolling(tc.bb_period).std()
|
||
upper = sma + tc.bb_std * std
|
||
lower = sma - tc.bb_std * std
|
||
bb_range = (upper - lower).replace(0, np.nan)
|
||
bb_pos = (close - lower) / bb_range
|
||
|
||
v = bb_pos.values
|
||
sig = np.where(v > 1.0, -1.0,
|
||
np.where(v < 0.0, 1.0,
|
||
np.where(v > 0.8, -1.0,
|
||
np.where(v < 0.2, 1.0, 0.0))))
|
||
|
||
conf = np.where((v > 1.0) | (v < 0.0), 65.0,
|
||
np.where((v > 0.8) | (v < 0.2), 45.0, 30.0))
|
||
|
||
return bb_pos, pd.Series(sig, index=close.index), pd.Series(conf, index=close.index)
|
||
|
||
# ── Stochastic ────────────────────────────────────────────────
|
||
|
||
def stochastic(
|
||
self, high: pd.Series, low: pd.Series, close: pd.Series
|
||
) -> Tuple[pd.Series, pd.Series, pd.Series]:
|
||
"""Returns (k_line, signal [-1..+1], confidence)."""
|
||
tc = self.tc
|
||
ll = low.rolling(tc.stoch_k).min()
|
||
hh = high.rolling(tc.stoch_k).max()
|
||
denom = (hh - ll).replace(0, np.nan)
|
||
k = (100 * (close - ll) / denom).fillna(50.0)
|
||
d = k.rolling(tc.stoch_d).mean().fillna(50.0)
|
||
|
||
kv, dv = k.values, d.values
|
||
ob, os_ = tc.stoch_overbought, tc.stoch_oversold
|
||
|
||
sig = np.where((kv >= ob) & (dv >= ob), -1.0,
|
||
np.where((kv <= os_) & (dv <= os_), 1.0, 0.0))
|
||
|
||
conf = np.where((kv >= ob) & (dv >= ob), 60.0,
|
||
np.where((kv <= os_) & (dv <= os_), 60.0, 30.0))
|
||
|
||
return k, pd.Series(sig, index=close.index), pd.Series(conf, index=close.index)
|
||
|
||
# ── ADX ───────────────────────────────────────────────────────
|
||
|
||
def adx(
|
||
self, high: pd.Series, low: pd.Series, close: pd.Series
|
||
) -> Tuple[pd.Series, pd.Series, pd.Series, pd.Series]:
|
||
"""Returns (adx, plus_di, minus_di, signal [-1..0..+1])."""
|
||
tc = self.tc
|
||
tr = pd.concat([
|
||
high - low,
|
||
(high - close.shift()).abs(),
|
||
(low - close.shift()).abs(),
|
||
], axis=1).max(axis=1)
|
||
|
||
p_dm = high.diff()
|
||
m_dm = -low.diff()
|
||
p_dm = p_dm.where((p_dm > m_dm) & (p_dm > 0), 0.0)
|
||
m_dm = m_dm.where((m_dm > p_dm) & (m_dm > 0), 0.0)
|
||
|
||
atr = self._wilder(tr, tc.adx_period)
|
||
pdi = 100 * self._wilder(p_dm, tc.adx_period) / atr
|
||
mdi = 100 * self._wilder(m_dm, tc.adx_period) / atr
|
||
dx = 100 * (pdi - mdi).abs() / (pdi + mdi).replace(0, np.nan)
|
||
adx_s = self._wilder(dx.fillna(0), tc.adx_period)
|
||
|
||
av, pv, mv = adx_s.values, pdi.values, mdi.values
|
||
trending = av >= tc.adx_strong_trend
|
||
sig = np.where(trending & (pv > mv), 1.0,
|
||
np.where(trending & (mv > pv), -1.0, 0.0))
|
||
|
||
adx_conf = np.where(av >= tc.adx_very_strong, 80.0,
|
||
np.where(av >= tc.adx_strong_trend, 65.0, 40.0))
|
||
|
||
return adx_s, pdi, mdi, pd.Series(sig, index=close.index), pd.Series(adx_conf, index=close.index)
|
||
|
||
# ── ATR ───────────────────────────────────────────────────────
|
||
|
||
def atr(self, high: pd.Series, low: pd.Series, close: pd.Series) -> pd.Series:
|
||
tc = self.tc
|
||
tr = pd.concat([
|
||
high - low,
|
||
(high - close.shift()).abs(),
|
||
(low - close.shift()).abs(),
|
||
], axis=1).max(axis=1)
|
||
return self._wilder(tr, tc.atr_period)
|
||
|
||
# ── Ichimoku ──────────────────────────────────────────────────
|
||
|
||
def ichimoku(
|
||
self, high: pd.Series, low: pd.Series, close: pd.Series
|
||
) -> Tuple[pd.Series, pd.Series]:
|
||
"""Returns (score [-4..+4], confidence)."""
|
||
tc = self.tc
|
||
tenkan = (high.rolling(tc.ichimoku_tenkan).max() +
|
||
low.rolling(tc.ichimoku_tenkan).min()) / 2
|
||
kijun = (high.rolling(tc.ichimoku_kijun).max() +
|
||
low.rolling(tc.ichimoku_kijun).min()) / 2
|
||
sa = (tenkan + kijun) / 2
|
||
sb = (high.rolling(tc.ichimoku_senkou_b).max() +
|
||
low.rolling(tc.ichimoku_senkou_b).min()) / 2
|
||
|
||
cloud_top = np.maximum(sa.values, sb.values)
|
||
cloud_bot = np.minimum(sa.values, sb.values)
|
||
cv, tv, kv = close.values, tenkan.values, kijun.values
|
||
|
||
price_score = np.where(cv > cloud_top, 2.0,
|
||
np.where(cv < cloud_bot, -2.0, 0.0))
|
||
tk_score = np.sign(tv - kv).astype(float)
|
||
cloud_score = np.sign(sa.values - sb.values).astype(float)
|
||
total = price_score + tk_score + cloud_score
|
||
|
||
conf = np.where(np.abs(total) >= 3, 75.0,
|
||
np.where(np.abs(total) >= 1, 55.0, 35.0))
|
||
|
||
return (
|
||
pd.Series(total, index=close.index),
|
||
pd.Series(conf, index=close.index),
|
||
)
|
||
|
||
# ── Master compute ────────────────────────────────────────────
|
||
|
||
def compute_all(
|
||
self, df: pd.DataFrame, disabled: Optional[List[str]] = None
|
||
) -> Dict[str, pd.Series]:
|
||
"""
|
||
Run all indicators on the full DataFrame, return a flat series dict.
|
||
Keys: ema_norm, ema_conf, ema200, rsi_val, rsi_sig, rsi_conf,
|
||
macd_hist, macd_sig, macd_conf, bb_pos, bb_sig, bb_conf,
|
||
stoch_k, stoch_sig, stoch_conf, adx_val, adx_conf, pdi, mdi,
|
||
adx_sig, atr, ichi_score, ichi_conf.
|
||
"""
|
||
disabled = set(disabled or [])
|
||
c, h, lo = df["Close"], df["High"], df["Low"]
|
||
out: Dict[str, pd.Series] = {}
|
||
|
||
if "ema_trend" not in disabled:
|
||
norm, ema_c, ema200 = self.ema_alignment(c)
|
||
out["ema_norm"] = norm
|
||
out["ema_conf"] = ema_c
|
||
out["ema200"] = ema200
|
||
|
||
if "rsi" not in disabled:
|
||
rv, rs, rc = self.rsi(c)
|
||
out["rsi_val"] = rv
|
||
out["rsi_sig"] = rs
|
||
out["rsi_conf"] = rc
|
||
|
||
if "macd" not in disabled:
|
||
mh, ms, mc = self.macd(c)
|
||
out["macd_hist"] = mh
|
||
out["macd_sig"] = ms
|
||
out["macd_conf"] = mc
|
||
|
||
if "bollinger" not in disabled:
|
||
bp, bs, bc = self.bollinger(c)
|
||
out["bb_pos"] = bp
|
||
out["bb_sig"] = bs
|
||
out["bb_conf"] = bc
|
||
|
||
if "stochastic" not in disabled:
|
||
sk, ss, sc = self.stochastic(h, lo, c)
|
||
out["stoch_k"] = sk
|
||
out["stoch_sig"] = ss
|
||
out["stoch_conf"] = sc
|
||
|
||
if "adx" not in disabled:
|
||
av, pdi, mdi, as_, ac = self.adx(h, lo, c)
|
||
out["adx_val"] = av
|
||
out["adx_conf"] = ac
|
||
out["pdi"] = pdi
|
||
out["mdi"] = mdi
|
||
out["adx_sig"] = as_
|
||
|
||
out["atr"] = self.atr(h, lo, c)
|
||
|
||
if "ichimoku" not in disabled:
|
||
is_, ic = self.ichimoku(h, lo, c)
|
||
out["ichi_score"] = is_
|
||
out["ichi_conf"] = ic
|
||
|
||
return out
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════
|
||
# VECTORISED COMPOSITE SCORING (GPU or NumPy)
|
||
# ══════════════════════════════════════════════════════════════════
|
||
|
||
# Per-indicator base weights
|
||
# Optimised 2026-05-07 via 36-combo sweep on H4 2022-2026 local data:
|
||
# ema_trend ×3, macd ×3, stochastic ×2, adx ×0.5, ichimoku ×0.5, rsi=0, bollinger=0
|
||
# Combined result: +21.30% / -8.52% DD / Sharpe 0.47 vs baseline +7.12% / -9.05% DD / 0.23
|
||
# Original weights (engine.py defaults, pre-optimisation):
|
||
# ema_trend=1.2, rsi=0.3, macd=1.1, bollinger=0.9, stochastic=0.8, adx=1.0, ichimoku=1.1
|
||
_BASE_W = {
|
||
"ema_trend": 3.6,
|
||
"macd": 3.3,
|
||
"stochastic": 1.6,
|
||
"adx": 0.5,
|
||
"ichimoku": 0.55,
|
||
"rsi": 0.0, # ablated — net drag on H4 2022-2026
|
||
"bollinger": 0.0, # ablated — counter-trend noise on trending pair
|
||
}
|
||
|
||
# Regime-based weight multipliers — matches engine._compute_composite
|
||
_TREND_MULTS = {
|
||
"ema_trend": 1.5, "adx": 1.3, "macd": 1.2, "ichimoku": 1.3,
|
||
"rsi": 0.7, "bollinger": 0.6, "stochastic": 1.0,
|
||
}
|
||
_RANGE_MULTS = {
|
||
"rsi": 1.4, "stochastic": 1.3, "bollinger": 1.4,
|
||
"ema_trend": 0.6, "adx": 0.5, "ichimoku": 0.2, "macd": 1.0,
|
||
}
|
||
|
||
|
||
def compute_bar_scores(
|
||
indicators: Dict[str, pd.Series],
|
||
disabled: Optional[List[str]] = None,
|
||
weights: Optional[Dict[str, float]] = None,
|
||
) -> Tuple[np.ndarray, np.ndarray]:
|
||
"""
|
||
Vectorised composite scoring — same formula as engine._compute_composite.
|
||
|
||
composite_score = (Σ signal × weight × regime_mult × conf/100) / Σ weight × 50
|
||
Clipped to [-100, +100].
|
||
|
||
weights: override per-indicator base weights (default: _BASE_W).
|
||
Returns (score_arr, confidence_arr) shape (N,).
|
||
"""
|
||
disabled = set(disabled or [])
|
||
base_w = weights if weights is not None else _BASE_W
|
||
n = len(next(iter(indicators.values())))
|
||
xp = cp # CuPy or NumPy alias
|
||
|
||
# Determine regime per bar from ADX + EMA direction
|
||
if "adx_val" in indicators and "ema_norm" in indicators:
|
||
adx_v = xp.asarray(indicators["adx_val"].values)
|
||
is_trending = adx_v >= 25.0
|
||
else:
|
||
is_trending = xp.zeros(n, dtype=bool)
|
||
|
||
total_score = xp.zeros(n, dtype=float)
|
||
total_weight = xp.zeros(n, dtype=float)
|
||
total_conf_w = xp.zeros(n, dtype=float)
|
||
|
||
def _add(name: str, sig_arr: np.ndarray, conf_arr: np.ndarray):
|
||
nonlocal total_score, total_weight, total_conf_w
|
||
if name in disabled:
|
||
return
|
||
bw = base_w.get(name, 1.0)
|
||
if bw == 0.0:
|
||
return
|
||
tm = _TREND_MULTS.get(name, 1.0)
|
||
rm = _RANGE_MULTS.get(name, 1.0)
|
||
regime_mult = xp.where(is_trending, float(tm), float(rm))
|
||
w = bw * regime_mult
|
||
s = xp.asarray(sig_arr)
|
||
c = xp.asarray(conf_arr)
|
||
total_score = total_score + xp.nan_to_num(s * w * (c / 100.0))
|
||
total_weight = total_weight + xp.nan_to_num(w)
|
||
total_conf_w = total_conf_w + xp.nan_to_num(c * w)
|
||
|
||
# EMA trend: map norm → signal integer matching engine thresholds
|
||
if "ema_norm" in indicators:
|
||
en = xp.asarray(indicators["ema_norm"].values)
|
||
ema_sig = xp.where(en > 0.6, 2.0,
|
||
xp.where(en > 0.2, 1.0,
|
||
xp.where(en < -0.6, -2.0,
|
||
xp.where(en < -0.2, -1.0, 0.0))))
|
||
_add("ema_trend", ema_sig, xp.asarray(indicators["ema_conf"].values))
|
||
|
||
if "rsi_sig" in indicators:
|
||
_add("rsi", indicators["rsi_sig"].values, indicators["rsi_conf"].values)
|
||
|
||
if "macd_sig" in indicators:
|
||
_add("macd", indicators["macd_sig"].values, indicators["macd_conf"].values)
|
||
|
||
if "bb_sig" in indicators:
|
||
_add("bollinger", indicators["bb_sig"].values, indicators["bb_conf"].values)
|
||
|
||
if "stoch_sig" in indicators:
|
||
_add("stochastic", indicators["stoch_sig"].values, indicators["stoch_conf"].values)
|
||
|
||
if "adx_sig" in indicators:
|
||
_add("adx", indicators["adx_sig"].values, indicators["adx_conf"].values)
|
||
|
||
if "ichi_score" in indicators:
|
||
ichi_s = xp.asarray(indicators["ichi_score"].values)
|
||
ichi_sig = xp.clip(ichi_s / 2.0, -2.0, 2.0)
|
||
_add("ichimoku", ichi_sig, xp.asarray(indicators["ichi_conf"].values))
|
||
|
||
safe_w = xp.where(total_weight > 0, total_weight, 1.0)
|
||
score = xp.clip((total_score / safe_w) * 50.0, -100.0, 100.0)
|
||
conf = xp.where(total_weight > 0, total_conf_w / total_weight, 0.0)
|
||
|
||
# Bring results back to NumPy if we used CuPy
|
||
if _GPU:
|
||
score = cp.asnumpy(score)
|
||
conf = cp.asnumpy(conf)
|
||
return score.astype(float), conf.astype(float)
|
||
|
||
|
||
def align_to_primary(
|
||
secondary_series: pd.Series,
|
||
secondary_tf_min: int,
|
||
primary_index: pd.DatetimeIndex,
|
||
primary_tf_min: int,
|
||
) -> pd.Series:
|
||
"""
|
||
Forward-fill a lower-frequency signal to the primary TF index without
|
||
lookahead. At primary bar T (close = T + primary_tf_min), we use the
|
||
most recent secondary bar whose close time ≤ T + primary_tf_min.
|
||
"""
|
||
sec_close_idx = secondary_series.index + pd.Timedelta(minutes=secondary_tf_min)
|
||
pri_close_idx = primary_index + pd.Timedelta(minutes=primary_tf_min)
|
||
sec_at_close = pd.Series(secondary_series.values, index=sec_close_idx)
|
||
aligned = sec_at_close.reindex(pri_close_idx, method="ffill")
|
||
aligned.index = primary_index
|
||
return aligned
|
||
|
||
|
||
def compute_final_scores(
|
||
primary_score: np.ndarray,
|
||
primary_conf: np.ndarray,
|
||
m15_score: Optional[np.ndarray],
|
||
h4_score: Optional[np.ndarray],
|
||
ema200: Optional[pd.Series],
|
||
close: pd.Series,
|
||
ema_lo: float = 0.5,
|
||
ema_hi: float = 1.5,
|
||
ema_mult: float = 0.55,
|
||
) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
||
"""
|
||
Apply MTF blending, MTF agreement bonus/penalty, and EMA200 distance gate.
|
||
Mirrors signal_aggregator._compute_final_signal exactly.
|
||
|
||
Returns (final_score, final_conf, mtf_agree [bool], signal_int [-2..+2]).
|
||
"""
|
||
score = primary_score.copy()
|
||
conf = primary_conf.copy()
|
||
n = len(score)
|
||
mtf_agree = np.zeros(n, dtype=bool)
|
||
|
||
if m15_score is not None and h4_score is not None:
|
||
pri_bull = primary_score > 10
|
||
pri_bear = primary_score < -10
|
||
m15_bull = m15_score > 10
|
||
m15_bear = m15_score < -10
|
||
h4_bull = h4_score > 10
|
||
h4_bear = h4_score < -10
|
||
|
||
bull_ct = pri_bull.astype(int) + m15_bull.astype(int) + h4_bull.astype(int)
|
||
bear_ct = pri_bear.astype(int) + m15_bear.astype(int) + h4_bear.astype(int)
|
||
mtf_agree = (bull_ct >= 2) | (bear_ct >= 2)
|
||
|
||
# MTF blending (same weights as signal_aggregator)
|
||
all_scores = np.stack([primary_score, m15_score, h4_score], axis=1)
|
||
mtf_avg = all_scores.mean(axis=1)
|
||
score = np.where(mtf_agree,
|
||
0.6 * primary_score + 0.4 * mtf_avg,
|
||
primary_score)
|
||
conf = np.where(mtf_agree,
|
||
np.minimum(conf + 15.0, 95.0),
|
||
conf * 0.7)
|
||
|
||
# EMA200 distance gate
|
||
if ema200 is not None:
|
||
e200 = ema200.values
|
||
cv = close.values
|
||
valid = e200 > 0
|
||
ema_dist = np.where(valid, np.abs(cv - e200) / e200 * 100.0, 999.0)
|
||
gate_on = (ema_dist < ema_lo) | (ema_dist > ema_hi)
|
||
conf = np.where(gate_on, conf * ema_mult, conf)
|
||
|
||
# Signal integer from thresholds (matches signal_aggregator._compute_final_signal)
|
||
sig = np.where((score >= 40) & (conf >= 65), 2,
|
||
np.where((score >= 15) & (conf >= 50), 1,
|
||
np.where((score <= -40) & (conf >= 65), -2,
|
||
np.where((score <= -15) & (conf >= 50), -1, 0)))).astype(float)
|
||
|
||
return score, conf, mtf_agree, sig
|
||
|
||
|
||
def compute_grades(
|
||
score: np.ndarray,
|
||
conf: np.ndarray,
|
||
mtf_agree: np.ndarray,
|
||
) -> np.ndarray:
|
||
"""Assign quality grades — same thresholds as signal_aggregator."""
|
||
abs_s = np.abs(score)
|
||
grades = np.full(len(score), "F", dtype=object)
|
||
# Apply lowest-to-highest priority (each overwrites the one below)
|
||
grades = np.where(abs_s >= 10, "D", grades)
|
||
grades = np.where((abs_s >= 15) & (conf >= 45), "C", grades)
|
||
grades = np.where((abs_s >= 25) & (conf >= 55), "B", grades)
|
||
grades = np.where((abs_s >= 35) & (conf >= 65) & mtf_agree, "A", grades)
|
||
grades = np.where((abs_s >= 50) & (conf >= 75) & mtf_agree, "A+", grades)
|
||
return grades
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════
|
||
# SEQUENTIAL STATE MACHINE
|
||
# ══════════════════════════════════════════════════════════════════
|
||
|
||
def _bar_close(open_time: pd.Timestamp, tf_min: int) -> pd.Timestamp:
|
||
return open_time + pd.Timedelta(minutes=tf_min)
|
||
|
||
|
||
def _check_exit(pos: dict, bar: pd.Series, bt) -> Optional[dict]:
|
||
"""Mirror of WalkForwardBacktester._check_exit."""
|
||
high = float(bar["High"])
|
||
low = float(bar["Low"])
|
||
sl, tp = pos["sl"], pos["tp"]
|
||
slip = bt.slippage_pips * PIP_VALUE
|
||
half = (0.5 if bt.use_spread_cost else 0.0) * PIP_VALUE
|
||
|
||
if pos["direction"] == "BUY":
|
||
sl_hit = low <= sl
|
||
tp_hit = high >= tp
|
||
if sl_hit and tp_hit:
|
||
return {"price": sl - slip - half, "reason": "sl"} if bt.sl_first_on_tie \
|
||
else {"price": tp - slip - half, "reason": "tp"}
|
||
if sl_hit: return {"price": sl - slip - half, "reason": "sl"}
|
||
if tp_hit: return {"price": tp - slip - half, "reason": "tp"}
|
||
else:
|
||
sl_hit = high >= sl
|
||
tp_hit = low <= tp
|
||
if sl_hit and tp_hit:
|
||
return {"price": sl + slip + half, "reason": "sl"} if bt.sl_first_on_tie \
|
||
else {"price": tp + slip + half, "reason": "tp"}
|
||
if sl_hit: return {"price": sl + slip + half, "reason": "sl"}
|
||
if tp_hit: return {"price": tp + slip + half, "reason": "tp"}
|
||
return None
|
||
|
||
|
||
def _open_trade(
|
||
sig: int, score: float, conf: float, grade: str,
|
||
atr_v: float, next_bar: pd.Series, balance: float,
|
||
config: PlatformConfig,
|
||
) -> dict:
|
||
bt = config.backtest
|
||
sc = config.signals
|
||
|
||
raw_entry = float(next_bar["Open"])
|
||
direction = "BUY" if sig > 0 else "SELL"
|
||
if bt.invert_signal:
|
||
direction = "SELL" if direction == "BUY" else "BUY"
|
||
|
||
cost_price = (0.5 if bt.use_spread_cost else 0.0 + bt.slippage_pips) * PIP_VALUE
|
||
entry = raw_entry + cost_price if direction == "BUY" else raw_entry - cost_price
|
||
|
||
sl_dist = atr_v * sc.atr_sl_multiplier
|
||
tp_dist = sl_dist * sc.default_rr_ratio
|
||
risk_pips = max(sl_dist / PIP_VALUE, 1e-9)
|
||
|
||
if direction == "BUY":
|
||
sl = entry - sl_dist
|
||
tp = entry + tp_dist
|
||
else:
|
||
sl = entry + sl_dist
|
||
tp = entry - tp_dist
|
||
|
||
risk_amt = balance * (bt.risk_per_trade_pct / 100.0)
|
||
pip_val = risk_amt / risk_pips
|
||
|
||
entry_ts = next_bar.name
|
||
entry_hr = int(entry_ts.hour) if hasattr(entry_ts, "hour") else -1
|
||
entry_dow = int(entry_ts.dayofweek) if hasattr(entry_ts, "dayofweek") else -1
|
||
|
||
return {
|
||
"direction": direction,
|
||
"entry_time": next_bar.name,
|
||
"entry_price": entry,
|
||
"sl": sl, "tp": tp,
|
||
"risk_pips": risk_pips,
|
||
"pip_currency_value": pip_val,
|
||
"balance_at_open": balance,
|
||
"grade": grade, "score": score, "conf": conf,
|
||
"entry_hr": entry_hr, "entry_dow": entry_dow,
|
||
}
|
||
|
||
|
||
def _close_trade(
|
||
pos: dict, exit_time: pd.Timestamp, exit_info: dict,
|
||
balance: float, config: PlatformConfig,
|
||
) -> Trade:
|
||
exit_price = exit_info["price"]
|
||
reason = exit_info["reason"]
|
||
|
||
if pos["direction"] == "BUY":
|
||
pnl_pips = (exit_price - pos["entry_price"]) / PIP_VALUE
|
||
else:
|
||
pnl_pips = (pos["entry_price"] - exit_price) / PIP_VALUE
|
||
|
||
pnl_currency = pnl_pips * pos["pip_currency_value"]
|
||
pnl_pct = pnl_currency / pos["balance_at_open"] * 100.0
|
||
|
||
tf_min = TF_MINUTES[config.primary_timeframe]
|
||
dur_min = (exit_time - pos["entry_time"]).total_seconds() / 60.0
|
||
dur_bars = int(round(dur_min / tf_min))
|
||
|
||
return Trade(
|
||
entry_time=pos["entry_time"],
|
||
exit_time=exit_time,
|
||
direction=pos["direction"],
|
||
entry_price=pos["entry_price"],
|
||
exit_price=exit_price,
|
||
stop_loss=pos["sl"],
|
||
take_profit=pos["tp"],
|
||
exit_reason=reason,
|
||
risk_pips=pos["risk_pips"],
|
||
pnl_pips=pnl_pips,
|
||
pnl_pct=pnl_pct,
|
||
pnl_currency=pnl_currency,
|
||
duration_bars=dur_bars,
|
||
grade=pos["grade"],
|
||
score_at_entry=pos["score"],
|
||
confidence_at_entry=pos["conf"],
|
||
entry_hour=pos["entry_hr"],
|
||
entry_dow=pos["entry_dow"],
|
||
)
|
||
|
||
|
||
def _regime_passes(
|
||
decision_time: pd.Timestamp,
|
||
regime_adx: Optional[pd.Series],
|
||
bt,
|
||
regime_tf_min: int,
|
||
) -> bool:
|
||
if not bt.regime_filter:
|
||
return True
|
||
if regime_adx is None or regime_adx.empty:
|
||
return False
|
||
close_times = regime_adx.index + pd.Timedelta(minutes=regime_tf_min)
|
||
valid = regime_adx[close_times <= decision_time]
|
||
if valid.empty:
|
||
return False
|
||
last = float(valid.iloc[-1])
|
||
return not np.isnan(last) and last >= bt.regime_filter_min_adx
|
||
|
||
|
||
def _session_passes(
|
||
decision_time: pd.Timestamp,
|
||
recent_pnl: deque,
|
||
bt,
|
||
) -> bool:
|
||
active = bt.session_filter
|
||
if not active and bt.auto_session_filter:
|
||
if len(recent_pnl) >= bt.auto_session_lookback_trades:
|
||
mean_pnl = sum(recent_pnl) / len(recent_pnl)
|
||
active = mean_pnl < bt.auto_session_threshold_pct
|
||
if not active:
|
||
return True
|
||
return bt.session_start_hour <= int(decision_time.hour) < bt.session_end_hour
|
||
|
||
|
||
def run_state_machine(
|
||
primary_df: pd.DataFrame,
|
||
final_score: np.ndarray,
|
||
final_conf: np.ndarray,
|
||
final_sig: np.ndarray,
|
||
grades: np.ndarray,
|
||
atr_arr: np.ndarray,
|
||
regime_adx: Optional[pd.Series],
|
||
config: PlatformConfig,
|
||
warmup: int,
|
||
progress: bool = True,
|
||
) -> BacktestResult:
|
||
"""Sequential position management over pre-computed signal arrays."""
|
||
bt = config.backtest
|
||
ptf = config.primary_timeframe
|
||
ptf_min = TF_MINUTES[ptf]
|
||
rtf_min = TF_MINUTES[bt.regime_filter_timeframe]
|
||
|
||
balance = bt.initial_balance
|
||
equity_points = [(primary_df.index[warmup], balance)]
|
||
trades: List[Trade] = []
|
||
recent_pnl = deque(maxlen=max(1, bt.auto_session_lookback_trades))
|
||
open_pos: Optional[dict] = None
|
||
|
||
iterator = range(warmup, len(primary_df) - 1)
|
||
if progress:
|
||
try:
|
||
from tqdm import tqdm # type: ignore
|
||
iterator = tqdm(iterator, desc="Replaying bars", unit="bar", leave=False)
|
||
except ImportError:
|
||
pass
|
||
|
||
for t in iterator:
|
||
cur_bar = primary_df.iloc[t]
|
||
decision_time = _bar_close(primary_df.index[t], ptf_min)
|
||
|
||
# ── Manage open position ─────────────────────────────────
|
||
if open_pos is not None:
|
||
if cur_bar.name != open_pos["entry_time"]:
|
||
exit_info = _check_exit(open_pos, cur_bar, bt)
|
||
if exit_info:
|
||
closed = _close_trade(open_pos, cur_bar.name, exit_info, balance, config)
|
||
trades.append(closed)
|
||
balance += closed.pnl_currency
|
||
equity_points.append((cur_bar.name, balance))
|
||
recent_pnl.append(float(closed.pnl_pct))
|
||
open_pos = None
|
||
|
||
# ── Open new position ────────────────────────────────────
|
||
if open_pos is None:
|
||
grade = str(grades[t])
|
||
sig = int(final_sig[t])
|
||
score = float(final_score[t])
|
||
conf = float(final_conf[t])
|
||
atr_v = float(atr_arr[t])
|
||
|
||
if not _grade_passes(grade, bt.min_trade_grade): continue
|
||
if abs(score) < bt.min_abs_score: continue
|
||
if sig == 0: continue
|
||
if atr_v == 0 or np.isnan(atr_v): continue
|
||
if t + 1 >= len(primary_df): continue
|
||
if not _regime_passes(decision_time, regime_adx, bt, rtf_min): continue
|
||
if not _session_passes(decision_time, recent_pnl, bt): continue
|
||
|
||
next_bar = primary_df.iloc[t + 1]
|
||
open_pos = _open_trade(sig, score, conf, grade, atr_v, next_bar, balance, config)
|
||
|
||
# Force-close remaining position at end of data
|
||
if open_pos is not None:
|
||
last = primary_df.iloc[-1]
|
||
closed = _close_trade(
|
||
open_pos, last.name,
|
||
{"price": float(last["Close"]), "reason": "end_of_data"},
|
||
balance, config,
|
||
)
|
||
trades.append(closed)
|
||
balance += closed.pnl_currency
|
||
equity_points.append((last.name, balance))
|
||
|
||
eq_idx = pd.DatetimeIndex([p[0] for p in equity_points])
|
||
eq_val = [p[1] for p in equity_points]
|
||
equity_curve = pd.Series(eq_val, index=eq_idx)
|
||
|
||
elapsed = None
|
||
if len(equity_curve) >= 2:
|
||
elapsed = (equity_curve.index[-1] - equity_curve.index[0]).total_seconds() / 86400
|
||
|
||
metrics = compute_metrics(trades, equity_curve, bt.initial_balance, elapsed_days=elapsed)
|
||
return BacktestResult(
|
||
trades=trades,
|
||
equity_curve=equity_curve,
|
||
initial_balance=bt.initial_balance,
|
||
final_balance=balance,
|
||
metrics=metrics,
|
||
)
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════
|
||
# GPU BACKTESTER (orchestrator)
|
||
# ══════════════════════════════════════════════════════════════════
|
||
|
||
class GPUBacktester:
|
||
"""
|
||
Phase 1 — Vectorised indicator computation (GPU/NumPy, O(N))
|
||
Phase 2 — Sequential state machine (CPU, O(N), unavoidable)
|
||
"""
|
||
|
||
def __init__(self, config: PlatformConfig):
|
||
self.config = config
|
||
self.dp = ForexDataProvider(config)
|
||
self.vingine = VectorIndicators(config)
|
||
|
||
def load_data(
|
||
self,
|
||
use_local: bool = False,
|
||
data_dir: str = "market_data",
|
||
) -> Dict[Timeframe, pd.DataFrame]:
|
||
bt = self.config.backtest
|
||
timeframes = [self.config.primary_timeframe] + list(self.config.confirmation_timeframes)
|
||
if bt.regime_filter and bt.regime_filter_timeframe not in timeframes:
|
||
timeframes.append(bt.regime_filter_timeframe)
|
||
|
||
dfs: Dict[Timeframe, pd.DataFrame] = {}
|
||
for tf in timeframes:
|
||
if use_local:
|
||
df = self.dp.load_local_csv(
|
||
timeframe=tf, data_dir=data_dir,
|
||
start_date=bt.start_date, end_date=bt.end_date,
|
||
)
|
||
if df is None or df.empty:
|
||
logger.warning(f"No local CSV for {tf.value} — skipping")
|
||
continue
|
||
else:
|
||
df = self.dp.fetch_full_history(
|
||
symbol=self.config.symbol, timeframe=tf,
|
||
max_bars=bt.max_bars,
|
||
start_date=bt.start_date,
|
||
end_date=bt.end_date,
|
||
)
|
||
if bt.start_date:
|
||
df = df[df.index >= pd.Timestamp(bt.start_date, tz="UTC")]
|
||
if bt.end_date:
|
||
df = df[df.index <= pd.Timestamp(bt.end_date, tz="UTC")]
|
||
dfs[tf] = df
|
||
return dfs
|
||
|
||
def prepare_arrays(self, dfs: Dict[Timeframe, pd.DataFrame]) -> dict:
|
||
"""
|
||
Phase 1 only — compute all indicator arrays and return them as a
|
||
reusable bundle. Call this once, then pass the bundle to
|
||
score_and_run() many times with different weights/disabled sets.
|
||
"""
|
||
bt = self.config.backtest
|
||
primary_tf = self.config.primary_timeframe
|
||
primary_df = dfs.get(primary_tf)
|
||
|
||
if primary_df is None or primary_df.empty:
|
||
return {}
|
||
|
||
warmup = max(
|
||
self.config.technical.ema_trend,
|
||
self.config.technical.ichimoku_senkou_b,
|
||
200,
|
||
) + 30
|
||
|
||
disabled = list(self.config.technical.disabled_indicators or [])
|
||
ptf_min = TF_MINUTES[primary_tf]
|
||
|
||
print(f" Phase 1 — Indicators [{_BACKEND}] on {len(primary_df):,} bars...")
|
||
primary_inds = self.vingine.compute_all(primary_df, disabled=[]) # compute all, disable at score time
|
||
|
||
# MTF raw indicator arrays (scored independently)
|
||
cf_inds_map: Dict[Timeframe, Dict[str, pd.Series]] = {}
|
||
for cf_tf in self.config.confirmation_timeframes:
|
||
cf_df = dfs.get(cf_tf)
|
||
if cf_df is not None and not cf_df.empty:
|
||
cf_inds_map[cf_tf] = self.vingine.compute_all(cf_df, disabled=[])
|
||
|
||
# Regime ADX
|
||
regime_adx: Optional[pd.Series] = None
|
||
if bt.regime_filter:
|
||
rf_df = dfs.get(bt.regime_filter_timeframe)
|
||
if rf_df is not None and not rf_df.empty:
|
||
rf_inds = self.vingine.compute_all(rf_df, disabled=[])
|
||
regime_adx = rf_inds.get("adx_val")
|
||
|
||
ema_lo = float(os.environ.get("FB2_EMA_LO", "0.5"))
|
||
ema_hi = float(os.environ.get("FB2_EMA_HI", "1.5"))
|
||
ema_mult = float(os.environ.get("FB2_EMA_MULT", "0.55"))
|
||
|
||
return {
|
||
"primary_df": primary_df,
|
||
"primary_inds": primary_inds,
|
||
"cf_inds_map": cf_inds_map,
|
||
"regime_adx": regime_adx,
|
||
"warmup": warmup,
|
||
"ptf_min": ptf_min,
|
||
"disabled_base": disabled,
|
||
"ema_lo": ema_lo,
|
||
"ema_hi": ema_hi,
|
||
"ema_mult": ema_mult,
|
||
}
|
||
|
||
def score_and_run(
|
||
self,
|
||
bundle: dict,
|
||
weights: Optional[Dict[str, float]] = None,
|
||
disabled: Optional[List[str]] = None,
|
||
progress: bool = False,
|
||
) -> BacktestResult:
|
||
"""
|
||
Phase 2 only — re-score bars with given weights/disabled set and
|
||
replay the state machine. Reuses pre-computed indicator arrays.
|
||
"""
|
||
if not bundle:
|
||
return BacktestResult(initial_balance=self.config.backtest.initial_balance)
|
||
|
||
primary_df = bundle["primary_df"]
|
||
primary_inds = bundle["primary_inds"]
|
||
cf_inds_map = bundle["cf_inds_map"]
|
||
regime_adx = bundle["regime_adx"]
|
||
warmup = bundle["warmup"]
|
||
ptf_min = bundle["ptf_min"]
|
||
ema_lo = bundle["ema_lo"]
|
||
ema_hi = bundle["ema_hi"]
|
||
ema_mult = bundle["ema_mult"]
|
||
|
||
dis = set(disabled if disabled is not None else bundle["disabled_base"])
|
||
|
||
primary_score, primary_conf = compute_bar_scores(
|
||
primary_inds, disabled=list(dis), weights=weights
|
||
)
|
||
|
||
# MTF scores with same weights/disabled
|
||
m15_score_arr: Optional[np.ndarray] = None
|
||
h4_score_arr: Optional[np.ndarray] = None
|
||
for cf_tf, cf_inds in cf_inds_map.items():
|
||
cf_score, _ = compute_bar_scores(cf_inds, disabled=list(dis), weights=weights)
|
||
cf_score_s = pd.Series(cf_score, index=cf_inds[next(iter(cf_inds))].index)
|
||
aligned = align_to_primary(cf_score_s, TF_MINUTES[cf_tf], primary_df.index, ptf_min)
|
||
if cf_tf == Timeframe.M15:
|
||
m15_score_arr = aligned.values.astype(float)
|
||
elif cf_tf == Timeframe.H4:
|
||
h4_score_arr = aligned.values.astype(float)
|
||
|
||
ema200_s = primary_inds.get("ema200")
|
||
final_score, final_conf, mtf_agree, final_sig = compute_final_scores(
|
||
primary_score, primary_conf,
|
||
m15_score_arr, h4_score_arr,
|
||
ema200_s, primary_df["Close"],
|
||
ema_lo, ema_hi, ema_mult,
|
||
)
|
||
grades = compute_grades(final_score, final_conf, mtf_agree)
|
||
atr_arr = primary_inds["atr"].values
|
||
|
||
return run_state_machine(
|
||
primary_df, final_score, final_conf, final_sig,
|
||
grades, atr_arr, regime_adx,
|
||
self.config, warmup, progress=progress,
|
||
)
|
||
|
||
def run(
|
||
self,
|
||
dfs=None,
|
||
progress: bool = True,
|
||
use_local: bool = False,
|
||
data_dir: str = "market_data",
|
||
) -> BacktestResult:
|
||
if dfs is None:
|
||
dfs = self.load_data(use_local=use_local, data_dir=data_dir)
|
||
|
||
bundle = self.prepare_arrays(dfs)
|
||
if not bundle:
|
||
return BacktestResult(initial_balance=self.config.backtest.initial_balance)
|
||
|
||
primary_df = bundle["primary_df"]
|
||
warmup = bundle["warmup"]
|
||
if len(primary_df) <= warmup + 1:
|
||
logger.warning(f"Insufficient data: {len(primary_df)} bars, need >{warmup}")
|
||
return BacktestResult(initial_balance=self.config.backtest.initial_balance)
|
||
|
||
replay_bars = len(primary_df) - warmup - 1
|
||
print(f" Phase 2 — Replaying {replay_bars:,} bars sequentially...")
|
||
|
||
return self.score_and_run(bundle, progress=progress)
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════
|
||
# OPTIMIZATION SWEEP
|
||
# ══════════════════════════════════════════════════════════════════
|
||
|
||
def _build_opt_combos() -> List[Tuple[str, Dict[str, float], List[str]]]:
|
||
"""
|
||
Build the full list of (label, weights_dict, disabled_list) tuples:
|
||
- 1 baseline run
|
||
- 7 ablation runs (each indicator off one at a time)
|
||
- 28 weight-multiplier runs (each indicator at ×0.5, ×1.5, ×2.0, ×3.0)
|
||
= 36 total combos.
|
||
"""
|
||
INDICATORS = list(_BASE_W.keys())
|
||
MULTS = [0.5, 1.5, 2.0, 3.0]
|
||
combos: List[Tuple[str, Dict[str, float], List[str]]] = []
|
||
|
||
combos.append(("baseline", _BASE_W.copy(), []))
|
||
|
||
for name in INDICATORS:
|
||
combos.append((f"ablate {name}", _BASE_W.copy(), [name]))
|
||
|
||
for name in INDICATORS:
|
||
for m in MULTS:
|
||
w = _BASE_W.copy()
|
||
w[name] = round(w[name] * m, 4)
|
||
combos.append((f"{name} x{m}", w, []))
|
||
|
||
return combos
|
||
|
||
|
||
def run_optimization(bt: GPUBacktester, dfs: Dict[Timeframe, pd.DataFrame]) -> None:
|
||
"""
|
||
Call prepare_arrays() once, then score_and_run() for every combo.
|
||
Prints a progress line per combo and a final sorted results table.
|
||
"""
|
||
combos = _build_opt_combos()
|
||
|
||
print(f"\n Optimization sweep — {len(combos)} combos")
|
||
print(f" Computing indicator arrays once (Phase 1)...")
|
||
bundle = bt.prepare_arrays(dfs)
|
||
if not bundle:
|
||
print(" ERROR: No data loaded.")
|
||
return
|
||
|
||
primary_df = bundle["primary_df"]
|
||
warmup = bundle["warmup"]
|
||
if len(primary_df) <= warmup + 1:
|
||
print(f" ERROR: Only {len(primary_df)} bars — need >{warmup+1}")
|
||
return
|
||
|
||
print(f" Replaying {len(combos)} × {len(primary_df)-warmup-1:,} bars...\n")
|
||
|
||
rows = []
|
||
for i, (label, w, dis) in enumerate(combos, 1):
|
||
r = bt.score_and_run(bundle, weights=w, disabled=dis, progress=False)
|
||
m = r.metrics
|
||
ret = m.get("total_return_pct", 0.0)
|
||
rows.append({
|
||
"label": label,
|
||
"trades": m.get("total_trades", 0),
|
||
"win_rate": m.get("win_rate", 0.0),
|
||
"pf": m.get("profit_factor", 0.0),
|
||
"return": ret,
|
||
"max_dd": m.get("max_drawdown_pct", 0.0),
|
||
"sharpe": m.get("sharpe", 0.0),
|
||
})
|
||
sign = "+" if ret >= 0 else ""
|
||
print(f" [{i:2d}/{len(combos)}] {label:<30s} "
|
||
f"trades={rows[-1]['trades']:3d} "
|
||
f"WR={rows[-1]['win_rate']:5.1f}% "
|
||
f"ret={sign}{ret:.2f}%")
|
||
|
||
rows.sort(key=lambda r: r["return"], reverse=True)
|
||
|
||
W = max(max(len(r["label"]) for r in rows), 30)
|
||
sep = "─" * (W + 55)
|
||
|
||
print(f"\n {sep}")
|
||
print(f" {'Label':<{W}} {'Trades':>6} {'WR%':>6} {'PF':>5} "
|
||
f"{'Return%':>8} {'MaxDD%':>7} {'Sharpe':>7}")
|
||
print(f" {sep}")
|
||
for r in rows:
|
||
pf_s = " inf" if r["pf"] == float("inf") else f"{r['pf']:5.2f}"
|
||
print(f" {r['label']:<{W}} {r['trades']:>6} "
|
||
f"{r['win_rate']:>6.1f} {pf_s} "
|
||
f"{r['return']:>+8.2f} {r['max_dd']:>7.2f} {r['sharpe']:>7.2f}")
|
||
print(f" {sep}")
|
||
print(f" Best: {rows[0]['label']} return={rows[0]['return']:+.2f}%\n")
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════
|
||
# CLI
|
||
# ══════════════════════════════════════════════════════════════════
|
||
|
||
def parse_args():
|
||
p = argparse.ArgumentParser(
|
||
description="ForexBot2 GPU Backtester — vectorised O(N) indicator engine",
|
||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||
epilog="""
|
||
Examples:
|
||
python backtest_gpu.py --start 2024-01-01
|
||
python backtest_gpu.py --start 2024-01-01 --regime-filter
|
||
python backtest_gpu.py --tf H4 --journal
|
||
python backtest_gpu.py --session-filter --journal-csv trades.csv
|
||
""",
|
||
)
|
||
p.add_argument("--tf", default="H1",
|
||
choices=["M5", "M15", "M30", "H1", "H4", "D1"])
|
||
p.add_argument("--bars", type=int, default=5000,
|
||
help="Max bars of history (default 5000)")
|
||
p.add_argument("--start", default=None, help="Start date YYYY-MM-DD")
|
||
p.add_argument("--end", default=None, help="End date YYYY-MM-DD")
|
||
p.add_argument("--min-grade", default="B",
|
||
choices=["A+", "A", "B", "C", "D", "F"])
|
||
p.add_argument("--regime-filter", action="store_true",
|
||
help="Gate signals on a higher-TF ADX trend confirmation")
|
||
p.add_argument("--regime-tf", default=None,
|
||
choices=["M15", "M30", "H1", "H4", "D1"],
|
||
help="Timeframe for regime ADX gate (default: one step above --tf)")
|
||
p.add_argument("--min-adx", type=float, default=25.0,
|
||
help="Min ADX for regime filter (default 25)")
|
||
p.add_argument("--session-filter", action="store_true",
|
||
help="Only trade UTC 07-16 (London + NY)")
|
||
p.add_argument("--session-start", type=int, default=7)
|
||
p.add_argument("--session-end", type=int, default=16)
|
||
p.add_argument("--journal", action="store_true",
|
||
help="Print last 10 trades")
|
||
p.add_argument("--journal-csv", default=None,
|
||
help="Write full trade journal to CSV")
|
||
p.add_argument("--local", action="store_true",
|
||
help="Load from local market_data/ CSVs instead of Twelve Data API")
|
||
p.add_argument("--data-dir", default="market_data",
|
||
help="Directory for local CSV files (default: market_data)")
|
||
p.add_argument("--optimize", action="store_true",
|
||
help="Sweep indicator weights and ablations (36 combos), print ranked table")
|
||
p.add_argument("--verbose", "-v", action="store_true")
|
||
return p.parse_args()
|
||
|
||
|
||
def build_config(args) -> PlatformConfig:
|
||
config = PlatformConfig()
|
||
tf_map = {
|
||
"M5": Timeframe.M5, "M15": Timeframe.M15, "M30": Timeframe.M30,
|
||
"H1": Timeframe.H1, "H4": Timeframe.H4, "D1": Timeframe.D1,
|
||
}
|
||
tf_conf = {
|
||
"M5": [Timeframe.M1, Timeframe.M15],
|
||
"M15": [Timeframe.M5, Timeframe.H1],
|
||
"M30": [Timeframe.M15, Timeframe.H1],
|
||
"H1": [Timeframe.M15, Timeframe.H4],
|
||
"H4": [Timeframe.H1, Timeframe.D1],
|
||
"D1": [Timeframe.H4, Timeframe.W1],
|
||
}
|
||
config.primary_timeframe = tf_map[args.tf]
|
||
config.confirmation_timeframes = tf_conf.get(args.tf, [Timeframe.M15, Timeframe.H4])
|
||
config.backtest.start_date = args.start
|
||
config.backtest.end_date = args.end
|
||
config.backtest.max_bars = args.bars
|
||
config.backtest.min_trade_grade = args.min_grade
|
||
config.backtest.regime_filter = args.regime_filter
|
||
config.backtest.regime_filter_min_adx = args.min_adx
|
||
# Regime TF: explicit flag > sensible default (one step above primary)
|
||
_default_regime_tf = {
|
||
"M5": "M15", "M15": "H1", "M30": "H1",
|
||
"H1": "H4", "H4": "D1", "D1": "D1",
|
||
}
|
||
regime_tf_str = args.regime_tf or _default_regime_tf.get(args.tf, "H4")
|
||
config.backtest.regime_filter_timeframe = tf_map[regime_tf_str]
|
||
config.backtest.session_filter = args.session_filter
|
||
config.backtest.session_start_hour = args.session_start
|
||
config.backtest.session_end_hour = args.session_end
|
||
return config
|
||
|
||
|
||
def print_results(result: BacktestResult, verbose_journal: bool = False):
|
||
m = result.metrics
|
||
ret = m.get("total_return_pct", 0)
|
||
pf = m.get("profit_factor", 0)
|
||
pf_s = "inf" if pf == float("inf") else f"{pf:.2f}"
|
||
|
||
print(f"\n {'─' * 76}")
|
||
print(f" Backtest Summary — USD/JPY [GPU Backtester]")
|
||
print(f" {'─' * 76}")
|
||
print(f" Trades: {m.get('total_trades', 0)}"
|
||
f" ({m.get('wins', 0)}W / {m.get('losses', 0)}L)")
|
||
print(f" Win Rate: {m.get('win_rate', 0):.1f}%")
|
||
print(f" Profit Factor: {pf_s}")
|
||
print(f" Expectancy: {m.get('expectancy_pips', 0):+.2f} pips/trade"
|
||
f" ({m.get('expectancy_per_trade', 0):+.2f} per trade)")
|
||
print(f" Initial Balance: ${result.initial_balance:,.2f}")
|
||
print(f" Final Balance: ${result.final_balance:,.2f}")
|
||
print(f" Total Return: {ret:+.2f}%")
|
||
print(f" CAGR: {m.get('cagr', 0):+.2f}%")
|
||
print(f" Max Drawdown: {m.get('max_drawdown_pct', 0):.2f}%")
|
||
print(f" Sharpe Ratio: {m.get('sharpe', 0):.2f}")
|
||
print(f" Sortino Ratio: {m.get('sortino', 0):.2f}")
|
||
print(f" Avg Duration: {m.get('avg_trade_duration_bars', 0):.1f} bars")
|
||
print(f" Win Streak: {m.get('longest_winning_streak', 0)}"
|
||
f" | Loss Streak: {m.get('longest_losing_streak', 0)}")
|
||
print(f" {'─' * 76}")
|
||
|
||
if verbose_journal and result.trades:
|
||
print(f"\n Trade Journal (last 10):")
|
||
print(f" {'#':<4} {'Entry Time':<22} {'Dir':<5} {'Entry':<9}"
|
||
f" {'Exit':<9} {'Reason':<6} {'Pips':>8} {'P&L%':>7}")
|
||
start = max(1, len(result.trades) - 9)
|
||
for i, t in enumerate(result.trades[-10:], start=start):
|
||
sign = "+" if t.pnl_currency > 0 else ""
|
||
print(f" {i:<4} {str(t.entry_time)[:19]:<22} {t.direction:<5}"
|
||
f" {t.entry_price:<9.3f} {t.exit_price:<9.3f} {t.exit_reason:<6}"
|
||
f" {t.pnl_pips:>+8.1f} {t.pnl_pct:>+6.2f}%")
|
||
|
||
|
||
def main():
|
||
args = parse_args()
|
||
logging.basicConfig(
|
||
level=logging.DEBUG if args.verbose else logging.WARNING,
|
||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||
datefmt="%H:%M:%S",
|
||
)
|
||
|
||
config = build_config(args)
|
||
bt = GPUBacktester(config)
|
||
|
||
print(f"\n ForexBot2 — GPU Backtester")
|
||
print(f" Backend : {_BACKEND}")
|
||
print(f" Data : {'Local CSV (' + args.data_dir + ')' if args.local else 'Twelve Data API'}")
|
||
print(f" Symbol : USD/JPY | TF: {args.tf}")
|
||
filters = []
|
||
if args.regime_filter:
|
||
rtf = config.backtest.regime_filter_timeframe.value
|
||
filters.append(f"regime {rtf} ADX≥{args.min_adx}")
|
||
if args.session_filter: filters.append(f"session ({args.session_start:02}-{args.session_end:02} UTC)")
|
||
if filters: print(f" Filters : {', '.join(filters)}")
|
||
print(f" {'─' * 40}")
|
||
|
||
_load_kw = dict(use_local=args.local, data_dir=args.data_dir)
|
||
|
||
if args.optimize:
|
||
dfs = bt.load_data(**_load_kw)
|
||
run_optimization(bt, dfs)
|
||
print()
|
||
return
|
||
|
||
result = bt.run(**_load_kw)
|
||
print_results(result, verbose_journal=args.journal)
|
||
|
||
if args.journal_csv and result.trades:
|
||
df_out = trades_to_dataframe(result.trades)
|
||
df_out.to_csv(args.journal_csv, index=False)
|
||
print(f"\n Trade journal written to {args.journal_csv}")
|
||
|
||
print()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|