17cb63e86e
## What changed ### backtest_gpu.py (new — ~1,300 lines) Standalone GPU-accelerated backtester completely separate from backtest.py. Computes all indicator series in one vectorised O(N) pass over the full price history, then replays positions with a sequential state machine. Key design: - VectorIndicators: pandas ewm/rolling (C-optimised) for all 7 indicators - CuPy GPU backend with automatic NumPy fallback (no hard dependency) - Two-phase API: prepare_arrays() once, score_and_run() many times - --optimize flag: 36-combo weight sweep (1 baseline + 7 ablations + 28 multiplier runs) — calls prepare_arrays() once, replays all combos in seconds - Full CLI: --tf, --local, --start/--end, --regime-filter, --regime-tf, --session-filter, --optimize, --journal, --journal-csv O(N) vs O(N^2): backtest.py recomputes all indicators per bar (O(N^2)); backtest_gpu.py computes each indicator series once and reads bar T in O(1). On 100,000 H1 bars: ~4 seconds vs >10 minutes. ### data_provider.py Added load_local_csv() method — reads MetaTrader tab-delimited CSV exports from market_data/ directory. Format: Time|Open|High|Low|Close|Volume|Spread (spread column dropped on load, timestamps localised to UTC). Applies the same _validate_and_clean() pipeline as the Twelve Data path. LOCAL_CSV_MAP maps each Timeframe enum to its filename. market_data/ files added (MetaTrader exports, UTC): USDJPY_M15.csv 100,000 bars 2022-05-04 to 2026-05-08 USDJPY_H1.csv 100,000 bars 2010-04-23 to 2026-05-08 USDJPY_H4.csv 25,855 bars 2010-04-22 to 2026-05-08 USDJPY_D1.csv 5,020 bars 2010-04-22 to 2026-05-08 16 years of H1/H4/D1 vs ~6 weeks of H1 on Twelve Data free plan. Use --local for all backtests; Twelve Data reserved for live signal feed. ### engine.py — optimised indicator weights 36-combo sweep on H4 2022-2026 found MACD and EMA trend are the dominant signals; RSI and Bollinger are net drag on this trending pair. Old → new weights: ema_trend: 1.2 → 3.6 (×3 — strongest directional signal) macd: 1.1 → 3.3 (×3 — ablating it is the single worst move: -42%) stochastic: 0.8 → 1.6 (×2 — clean timing, 40% WR at this level) adx: 1.0 → 0.5 (÷2 — partially redundant with EMA stack) ichimoku: 1.1 → 0.55 (÷2 — lags too much at H4, hurts in ranging) rsi: 0.3 → 0.0 (ablated — net drag across all test windows) bollinger: 0.9 → 0.0 (ablated — counter-trend noise on trending pair) RSI and Bollinger are still computed and displayed in live output for reference; weight=0 means they contribute nothing to the composite score. Combined result vs baseline (H4 2022-2026, local data): Return: +7.12% → +21.30% (+14.2pp) Max DD: -9.05% → -8.52% (tighter — gains did not come at expense of risk) Sharpe: 0.23 → 0.47 (2x improvement) Sortino: 1.38 → 1.91 Win Rate: 36.3% → 38.1% ### backtest.py Added 16-thread CPU parallelism for signal pre-computation via ThreadPoolExecutor. _precompute_all_signals() dispatches each bar to a worker; pandas/NumPy C extensions release the GIL enabling true parallel execution. Results are collected and replayed in the main loop, replacing the per-bar recompute. Also added --regime-tf CLI flag so regime filter timeframe can be specified independently of the primary TF. ### signal_aggregator.py EMA200 distance gate: confidence *= 0.55 when price is within 0.5% of EMA200 (chop zone) or beyond 1.5% (overextended). Sweet spot is 0.5-1.5% from EMA200 where trending momentum is cleanest. Strongest single filter found: lifted cumulative return +13.88% to +35.50% in ablation window. ### ENGINE_LOGIC.md (new) Full reference document covering: architecture, data pipeline, all indicator logic, composite scoring formula, optimised weights with rationale, regime classification, EMA200 gate, MTF alignment, grade system, backtesting results, filter catalogue, and configuration reference.
1300 lines
51 KiB
Python
1300 lines
51 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 ───────────────────────────────────────────────────
|
||
try:
|
||
import cupy as cp # type: ignore
|
||
_GPU = True
|
||
_BACKEND = "CuPy (GPU)"
|
||
except ImportError:
|
||
cp = np # type: ignore[assignment]
|
||
_GPU = False
|
||
_BACKEND = "NumPy (CPU — install cupy-cuda12x 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()
|