Files
ForexBot2/backtest_gpu.py
T
Harwinder a55aca21b1 Round-2 weight optimisation — reduce EMA trend, boost stochastic
GPU sweep (36 combos, RTX 4070 SUPER) on H4 2022-2026 relative to the
round-1 baseline revealed EMA trend was over-weighted at 3.6. Halving
it and nudging stochastic up yields cleaner, higher-quality signals.

Weight changes (backtest_gpu.py _BASE_W + engine.py):
  ema_trend:  3.6 -> 1.8  (over-weighted; halving tightens signal filter)
  stochastic: 1.6 -> 2.4  (x1.5; improved WR to 40.4% in sweep)
  macd:       3.3          (unchanged — dominant signal, kept as anchor)
  adx/ichimoku/rsi/bollinger: unchanged

Combined result vs round-1 (H4 local data 2022-2026):
  Trades:      160 -> 73   (-87 — filters out marginal setups)
  Win Rate:   38.1% -> 43.8%  (+5.7pp)
  Profit Factor: 1.19 -> 1.52
  Return:    +21.30% -> +23.86%  (+2.57pp)
  Max DD:     -8.52% -> -9.32%
  Sharpe:      0.47  ->  0.71   (+51%)
  Sortino:     1.91  ->  3.06   (+60%)

Fewer trades at much higher quality. The lower EMA weight means only
strong, multi-indicator confluences clear the grade threshold — the
73 remaining trades have 43.8% WR vs 38.1% and expectancy nearly 3x.
2026-05-07 22:24:48 -04:00

1324 lines
52 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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
# Round 1 optimisation 2026-05-07: ema_trend=3.6, macd=3.3, stoch=1.6, adx=0.5, ichi=0.55
# → +21.30% / -8.52% DD / Sharpe 0.47
# Round 2 optimisation 2026-05-08 (GPU sweep relative to round-1 baseline):
# ema_trend ×0.5 was best single move: +32.68% / -6.15% DD / Sharpe 0.74
# stochastic ×1.5 added modest improvement; macd kept unchanged
# 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": 1.8, # was 3.6 — over-weighted; halving lifts return +11pp, tightens DD
"macd": 3.3, # unchanged — dominant signal
"stochastic": 2.4, # was 1.6 — ×1.5 improved WR to 40.4%
"adx": 0.5, # unchanged
"ichimoku": 0.55, # unchanged
"rsi": 0.0, # ablated — confirmed: all multipliers give identical result
"bollinger": 0.0, # ablated — confirmed: all multipliers give identical result
}
# 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()