Files
ForexBot2/backtest.py
T
Harwinder 17cb63e86e GPU backtester, optimised weights, local CSV data pipeline
## 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.
2026-05-07 22:12:43 -04:00

668 lines
27 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.
"""
ForexBot2 — Walk-Forward Backtester + Parameter Optimizer
==========================================================
Replays Twelve Data USD/JPY history bar-by-bar through the same
SignalAggregator that drives live mode. The same code path that
trades USD/JPY in production is what produces these metrics — so
the backtest validates the engine, not a parallel implementation.
"""
from __future__ import annotations
import copy
import itertools
import logging
import multiprocessing as mp
import os
from collections import deque
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import replace
from typing import Deque, Dict, List, Optional, Tuple
import pandas as pd
from config import PlatformConfig, Timeframe, USDJPY, PIP_VALUE
from data_provider import ForexDataProvider
from metrics import BacktestResult, Trade, compute_metrics, trades_to_dataframe
from signal_aggregator import SignalAggregator, SignalReport, TradeSetup
logger = logging.getLogger(__name__)
# Period in minutes for each timeframe (used for close-time computation)
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:
"""True if `grade` is at least as good as `min_grade`."""
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)
# ══════════════════════════════════════════════════════════════════
# WALK-FORWARD BACKTESTER
# ══════════════════════════════════════════════════════════════════
class WalkForwardBacktester:
"""Single-pair, single-position event-driven backtester."""
def __init__(self, config: PlatformConfig):
self.config = config
self.aggregator = SignalAggregator(config)
self.data_provider = self.aggregator.data_provider
self._regime_adx: Optional[pd.Series] = None
self._regime_tf: Optional[Timeframe] = None
# ── Public ─────────────────────────────────────────────────────
def load_data(self) -> Dict[Timeframe, pd.DataFrame]:
"""Pull (or read from disk cache) all needed timeframes."""
timeframes = [self.config.primary_timeframe] + list(
self.config.confirmation_timeframes
)
bt = self.config.backtest
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:
df = self.data_provider.fetch_full_history(
symbol=self.config.symbol,
timeframe=tf,
max_bars=bt.max_bars,
start_date=bt.start_date,
end_date=bt.end_date,
)
df = self._apply_window(df)
dfs[tf] = df
return dfs
def _precompute_all_signals(
self,
dfs: Dict[Timeframe, pd.DataFrame],
primary_df: pd.DataFrame,
warmup: int,
progress: bool = True,
) -> Dict[int, "SignalReport"]:
"""Pre-compute signals for every bar in parallel using all CPU threads.
TechnicalEngine.analyze() is stateless (pure function of the df slice),
so concurrent calls are safe. NumPy/pandas C extensions release the GIL,
giving real parallelism across threads without pickling overhead.
Returns bar-index → SignalReport so the sequential state machine can
do O(1) lookups instead of recomputing indicators per bar.
"""
primary_tf = self.config.primary_timeframe
# Use all logical threads; cap at 2× physical cores to avoid over-subscription
n_workers = min(os.cpu_count() or 4, 16)
bar_indices = list(range(warmup, len(primary_df) - 1))
def _compute_bar(t: int) -> Tuple[int, "SignalReport"]:
decision_time = self._bar_close_time(primary_df.index[t], primary_tf)
sliced = self._slice_dfs(dfs, decision_time)
report = self.aggregator.analyze_from_dataframes(
symbol=self.config.symbol,
dfs=sliced,
use_news=False,
)
return t, report
precomputed: Dict[int, "SignalReport"] = {}
pbar = None
if progress:
try:
from tqdm import tqdm # type: ignore
pbar = tqdm(
total=len(bar_indices),
desc=f"Signals ({n_workers} threads)",
unit="bar",
leave=False,
)
except ImportError:
pass
with ThreadPoolExecutor(max_workers=n_workers) as executor:
futures = {executor.submit(_compute_bar, t): t for t in bar_indices}
for future in as_completed(futures):
t, report = future.result()
precomputed[t] = report
if pbar:
pbar.update(1)
if pbar:
pbar.close()
return precomputed
def run(
self,
dfs: Optional[Dict[Timeframe, pd.DataFrame]] = None,
progress: bool = True,
) -> BacktestResult:
if dfs is None:
dfs = self.load_data()
self._precompute_regime_filter(dfs)
primary_tf = self.config.primary_timeframe
primary_df = dfs.get(primary_tf)
if primary_df is None or primary_df.empty:
return BacktestResult(initial_balance=self.config.backtest.initial_balance)
# Warmup: enough bars for ema_trend & ichimoku_senkou_b on the primary TF
warmup = max(
self.config.technical.ema_trend,
self.config.technical.ichimoku_senkou_b,
200,
) + 30
if len(primary_df) <= warmup + 1:
logger.warning(
f"Insufficient data: {len(primary_df)} primary bars, "
f"need >{warmup}."
)
return BacktestResult(initial_balance=self.config.backtest.initial_balance)
# Pre-compute all bar signals in parallel before the sequential replay
precomputed = self._precompute_all_signals(
dfs, primary_df, warmup, progress=progress
)
bt = self.config.backtest
balance = bt.initial_balance
equity_points: List[Tuple[pd.Timestamp, float]] = [
(primary_df.index[warmup], balance)
]
trades: List[Trade] = []
# Auto-session feedback: rolling pnl_pct over last K closed trades.
# When mean falls below threshold, the session filter activates;
# once expectancy recovers, it releases.
self._recent_pnl_pcts: Deque[float] = deque(
maxlen=max(1, bt.auto_session_lookback_trades)
)
open_position: Optional[Dict] = None
# Optional progress bar for the state-machine replay pass
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:
decision_time = self._bar_close_time(primary_df.index[t], primary_tf)
# ── Step 1: manage any open position ───────────────────
if open_position is not None:
next_bar = primary_df.iloc[t]
exit_info = self._check_exit(open_position, next_bar)
if exit_info is not None:
closed = self._close_trade(
open_position, next_bar.name, exit_info, balance
)
trades.append(closed)
balance += closed.pnl_currency
equity_points.append((next_bar.name, balance))
self._recent_pnl_pcts.append(float(closed.pnl_pct))
open_position = None
# ── Step 2: maybe open a new position ──────────────────
if open_position is None or not bt.one_position_only:
report = precomputed[t]
if (
_grade_passes(report.signal_quality, bt.min_trade_grade)
and abs(float(report.final_score)) >= bt.min_abs_score
and report.trade_setup is not None
and t + 1 < len(primary_df)
and self._regime_passes(decision_time)
and self._session_passes(decision_time)
):
next_bar = primary_df.iloc[t + 1]
open_position = self._open_trade(report, next_bar, balance)
# ── Force-close any still-open position at end of data ────
if open_position is not None:
last_bar = primary_df.iloc[-1]
closed = self._close_trade(
open_position,
last_bar.name,
{"price": float(last_bar["Close"]), "reason": "end_of_data"},
balance,
)
trades.append(closed)
balance += closed.pnl_currency
equity_points.append((last_bar.name, balance))
# Build equity curve
eq_index = [p[0] for p in equity_points]
eq_values = [p[1] for p in equity_points]
equity_curve = pd.Series(eq_values, index=pd.DatetimeIndex(eq_index))
elapsed_days: Optional[float] = None
if len(equity_curve) >= 2:
elapsed_days = (equity_curve.index[-1] - equity_curve.index[0]).total_seconds() / 86400
metrics = compute_metrics(
trades, equity_curve, bt.initial_balance, elapsed_days=elapsed_days
)
return BacktestResult(
trades=trades,
equity_curve=equity_curve,
initial_balance=bt.initial_balance,
final_balance=balance,
metrics=metrics,
)
# ── Private: regime gate ───────────────────────────────────────
def _precompute_regime_filter(self, dfs: Dict[Timeframe, pd.DataFrame]) -> None:
"""Pre-compute ADX on the regime-filter TF; cached for the whole run."""
bt = self.config.backtest
if not bt.regime_filter:
self._regime_adx = None
self._regime_tf = None
return
tf = bt.regime_filter_timeframe
df = dfs.get(tf)
if df is None or df.empty:
logger.warning(
f"Regime filter requested ({tf.value}) but no data available; "
f"all signals will be skipped."
)
self._regime_adx = pd.Series(dtype=float)
self._regime_tf = tf
return
self._regime_tf = tf
self._regime_adx = self._compute_adx_series(df, self.config.technical.adx_period)
def _regime_passes(self, decision_time: pd.Timestamp) -> bool:
bt = self.config.backtest
if not bt.regime_filter:
return True
if self._regime_adx is None or self._regime_adx.empty:
return False
period_min = TF_MINUTES[self._regime_tf]
close_times = self._regime_adx.index + pd.Timedelta(minutes=period_min)
valid = self._regime_adx[close_times <= decision_time]
if valid.empty:
return False
last = valid.iloc[-1]
if pd.isna(last):
return False
return float(last) >= bt.regime_filter_min_adx
def _session_passes(self, decision_time: pd.Timestamp) -> bool:
"""UTC hour gate — decision_time = entry bar's open time.
Filter is active when EITHER manual `session_filter` is on, OR
`auto_session_filter` is on and the rolling mean pnl_pct over the
last N closed trades is below threshold.
"""
bt = self.config.backtest
active = bt.session_filter
if not active and bt.auto_session_filter:
recent = getattr(self, "_recent_pnl_pcts", None)
if recent is not None and len(recent) >= bt.auto_session_lookback_trades:
mean_pnl = sum(recent) / len(recent)
active = mean_pnl < bt.auto_session_threshold_pct
if not active:
return True
h = int(decision_time.hour)
return bt.session_start_hour <= h < bt.session_end_hour
@staticmethod
def _compute_adx_series(df: pd.DataFrame, period: int = 14) -> pd.Series:
"""Wilder's ADX series — same algorithm as engine._compute_adx."""
import numpy as np
high, low, close = df["High"], df["Low"], df["Close"]
tr = pd.concat([
high - low,
(high - close.shift()).abs(),
(low - close.shift()).abs(),
], axis=1).max(axis=1)
plus_dm = high.diff()
minus_dm = -low.diff()
plus_dm = plus_dm.where((plus_dm > minus_dm) & (plus_dm > 0), 0.0)
minus_dm = minus_dm.where((minus_dm > plus_dm) & (minus_dm > 0), 0.0)
atr_s = tr.ewm(alpha=1/period, min_periods=period).mean()
plus_di = 100 * (plus_dm.ewm(alpha=1/period, min_periods=period).mean() / atr_s)
minus_di = 100 * (minus_dm.ewm(alpha=1/period, min_periods=period).mean() / atr_s)
dx = 100 * (plus_di - minus_di).abs() / (plus_di + minus_di).replace(0, np.nan)
return dx.ewm(alpha=1/period, min_periods=period).mean()
# ── Private: data slicing ──────────────────────────────────────
def _apply_window(self, df: pd.DataFrame) -> pd.DataFrame:
bt = self.config.backtest
if bt.start_date:
start = pd.Timestamp(bt.start_date, tz="UTC")
df = df[df.index >= start]
if bt.end_date:
end = pd.Timestamp(bt.end_date, tz="UTC")
df = df[df.index <= end]
return df
@staticmethod
def _bar_close_time(open_time: pd.Timestamp, tf: Timeframe) -> pd.Timestamp:
return open_time + pd.Timedelta(minutes=TF_MINUTES[tf])
def _slice_dfs(
self, dfs: Dict[Timeframe, pd.DataFrame], decision_time: pd.Timestamp
) -> Dict[Timeframe, pd.DataFrame]:
"""Return only bars whose close time ≤ decision_time (no lookahead)."""
sliced: Dict[Timeframe, pd.DataFrame] = {}
for tf, df in dfs.items():
close_times = df.index + pd.Timedelta(minutes=TF_MINUTES[tf])
sliced[tf] = df[close_times <= decision_time]
return sliced
# ── Private: trade execution ───────────────────────────────────
def _open_trade(
self, report: SignalReport, next_bar: pd.Series, balance: float
) -> Dict:
"""Open at next bar's open, applying spread + slippage."""
ts: TradeSetup = report.trade_setup # type: ignore[assignment]
bt = self.config.backtest
raw_entry = float(next_bar["Open"])
# Sanity-test inversion: flip direction and mirror SL/TP around entry
direction = ts.direction
sl = ts.stop_loss
tp = ts.take_profit_1
if bt.invert_signal:
direction = "SELL" if direction == "BUY" else "BUY"
sl, tp = (2 * raw_entry - sl), (2 * raw_entry - tp)
# Cost in price units = (half-spread + slippage) × pip_value
half_spread_pips = 0.5 if bt.use_spread_cost else 0.0
cost_pips = half_spread_pips + bt.slippage_pips
cost_price = cost_pips * PIP_VALUE
if direction == "BUY":
entry_price = raw_entry + cost_price # buy higher (worse)
else:
entry_price = raw_entry - cost_price # sell lower (worse)
# Position sizing — risk a fixed % of current balance
risk_pct = bt.risk_per_trade_pct / 100.0
risk_amount = balance * risk_pct
risk_pips = max(ts.risk_pips, 1e-9)
pip_currency_value = risk_amount / risk_pips # $ per pip
# ── Diagnostic snapshot at entry ─────────────────────────
analysis = report.primary_analysis
adx_val = 0.0
atr_pct = 0.0
rsi_val = 50.0
ema_dist = 0.0
if analysis is not None:
adx_sig = analysis.indicators.get("adx")
if adx_sig is not None:
adx_val = float(adx_sig.value)
atr_pct = float(analysis.key_levels.get("atr_pct", 0.0))
rsi_sig = analysis.indicators.get("rsi")
if rsi_sig is not None:
rsi_val = float(rsi_sig.value)
ema_trend = analysis.key_levels.get("ema_trend")
if ema_trend and ema_trend > 0:
ema_dist = (raw_entry - ema_trend) / ema_trend * 100.0
# Per-timeframe scores from MTF map (keys are tf.value strings)
m15_s = float(report.mtf_scores.get("15min", 0.0))
h1_s = float(report.mtf_scores.get("1h", 0.0))
h4_s = float(report.mtf_scores.get("4h", 0.0))
entry_ts = next_bar.name
entry_hour = 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_price,
"stop_loss": sl,
"take_profit": tp,
"risk_pips": risk_pips,
"pip_currency_value": pip_currency_value,
"balance_at_open": balance,
"grade": report.signal_quality,
"score_at_entry": float(report.final_score),
"confidence_at_entry": float(report.final_confidence),
"bar_index_open": next_bar.name,
# Diagnostics
"entry_hour": entry_hour,
"entry_dow": entry_dow,
"adx_at_entry": adx_val,
"atr_pct_at_entry": atr_pct,
"market_regime": report.market_regime or "",
"volatility_regime": report.volatility_regime or "",
"mtf_agreement": bool(report.mtf_agreement),
"m15_score": m15_s,
"h1_score": h1_s,
"h4_score": h4_s,
"rsi_at_entry": rsi_val,
"ema_distance_pct": ema_dist,
}
def _check_exit(
self, position: Dict, bar: pd.Series
) -> Optional[Dict]:
"""Return {price, reason} if SL or TP hit, else None.
On the entry bar (bar with same timestamp as position's entry_time),
we already filled at the open — only check subsequent bars' OHLC.
"""
if bar.name == position["bar_index_open"]:
return None # entry bar — no exit check
bt = self.config.backtest
high = float(bar["High"])
low = float(bar["Low"])
sl = position["stop_loss"]
tp = position["take_profit"]
slip = bt.slippage_pips * PIP_VALUE
half_spread = (0.5 if bt.use_spread_cost else 0.0) * PIP_VALUE
if position["direction"] == "BUY":
sl_hit = low <= sl
tp_hit = high >= tp
if sl_hit and tp_hit:
# Conservative: SL first
return {"price": sl - slip - half_spread, "reason": "sl"} if bt.sl_first_on_tie else \
{"price": tp - slip - half_spread, "reason": "tp"}
if sl_hit:
return {"price": sl - slip - half_spread, "reason": "sl"}
if tp_hit:
return {"price": tp - slip - half_spread, "reason": "tp"}
else: # SELL
sl_hit = high >= sl
tp_hit = low <= tp
if sl_hit and tp_hit:
return {"price": sl + slip + half_spread, "reason": "sl"} if bt.sl_first_on_tie else \
{"price": tp + slip + half_spread, "reason": "tp"}
if sl_hit:
return {"price": sl + slip + half_spread, "reason": "sl"}
if tp_hit:
return {"price": tp + slip + half_spread, "reason": "tp"}
return None
def _close_trade(
self, position: Dict, exit_time: pd.Timestamp, exit_info: Dict, balance: float
) -> Trade:
exit_price = exit_info["price"]
reason = exit_info["reason"]
if position["direction"] == "BUY":
pnl_pips = (exit_price - position["entry_price"]) / PIP_VALUE
else:
pnl_pips = (position["entry_price"] - exit_price) / PIP_VALUE
pnl_currency = pnl_pips * position["pip_currency_value"]
pnl_pct = pnl_currency / position["balance_at_open"] * 100
# Duration in primary-TF bars (timestamp diff / period)
period_min = TF_MINUTES[self.config.primary_timeframe]
duration_minutes = (exit_time - position["entry_time"]).total_seconds() / 60
duration_bars = int(round(duration_minutes / period_min))
return Trade(
entry_time=position["entry_time"],
exit_time=exit_time,
direction=position["direction"],
entry_price=position["entry_price"],
exit_price=exit_price,
stop_loss=position["stop_loss"],
take_profit=position["take_profit"],
exit_reason=reason,
risk_pips=position["risk_pips"],
pnl_pips=pnl_pips,
pnl_pct=pnl_pct,
pnl_currency=pnl_currency,
duration_bars=duration_bars,
grade=position["grade"],
score_at_entry=position["score_at_entry"],
confidence_at_entry=position["confidence_at_entry"],
entry_hour=position.get("entry_hour", -1),
entry_dow=position.get("entry_dow", -1),
adx_at_entry=position.get("adx_at_entry", 0.0),
atr_pct_at_entry=position.get("atr_pct_at_entry", 0.0),
market_regime=position.get("market_regime", ""),
volatility_regime=position.get("volatility_regime", ""),
mtf_agreement=position.get("mtf_agreement", False),
m15_score=position.get("m15_score", 0.0),
h1_score=position.get("h1_score", 0.0),
h4_score=position.get("h4_score", 0.0),
rsi_at_entry=position.get("rsi_at_entry", 50.0),
ema_distance_pct=position.get("ema_distance_pct", 0.0),
)
# ══════════════════════════════════════════════════════════════════
# PARAMETER OPTIMIZER (PARALLEL)
# ══════════════════════════════════════════════════════════════════
def _apply_overrides(config: PlatformConfig, overrides: Dict[str, float]) -> PlatformConfig:
"""Return a deep-copied config with parameter overrides applied."""
new_config = copy.deepcopy(config)
if "atr_sl_multiplier" in overrides:
new_config.signals.atr_sl_multiplier = float(overrides["atr_sl_multiplier"])
if "default_rr_ratio" in overrides:
new_config.signals.default_rr_ratio = float(overrides["default_rr_ratio"])
if "min_trade_grade" in overrides:
new_config.backtest.min_trade_grade = str(overrides["min_trade_grade"])
if "min_abs_score" in overrides:
new_config.backtest.min_abs_score = float(overrides["min_abs_score"])
if "invert_signal" in overrides:
new_config.backtest.invert_signal = bool(overrides["invert_signal"])
if "regime_filter" in overrides:
new_config.backtest.regime_filter = bool(overrides["regime_filter"])
if "regime_filter_min_adx" in overrides:
new_config.backtest.regime_filter_min_adx = float(overrides["regime_filter_min_adx"])
if "session_filter" in overrides:
new_config.backtest.session_filter = bool(overrides["session_filter"])
if "session_start_hour" in overrides:
new_config.backtest.session_start_hour = int(overrides["session_start_hour"])
if "session_end_hour" in overrides:
new_config.backtest.session_end_hour = int(overrides["session_end_hour"])
if "auto_session_filter" in overrides:
new_config.backtest.auto_session_filter = bool(overrides["auto_session_filter"])
if "auto_session_lookback_trades" in overrides:
new_config.backtest.auto_session_lookback_trades = int(overrides["auto_session_lookback_trades"])
if "auto_session_threshold_pct" in overrides:
new_config.backtest.auto_session_threshold_pct = float(overrides["auto_session_threshold_pct"])
if "risk_per_trade_pct" in overrides:
new_config.backtest.risk_per_trade_pct = float(overrides["risk_per_trade_pct"])
if "disable_indicator" in overrides:
v = overrides["disable_indicator"]
new_config.technical.disabled_indicators = [str(v)] if v else []
return new_config
def _run_single_combo(args: Tuple[PlatformConfig, Dict[str, float]]) -> BacktestResult:
"""Worker function — must be top-level for multiprocessing.Pool."""
base_config, overrides = args
config = _apply_overrides(base_config, overrides)
backtester = WalkForwardBacktester(config)
# Workers reuse the disk parquet cache, so no Twelve Data fetch needed
result = backtester.run(progress=False)
result.params = overrides
return result
class Optimizer:
"""Embarrassingly parallel grid search over BacktestConfig.param_grid."""
def __init__(self, config: PlatformConfig):
self.config = config
def run_grid(
self,
param_grid: Optional[Dict[str, List[float]]] = None,
prewarm_cache: bool = True,
) -> List[BacktestResult]:
if param_grid is None:
param_grid = self.config.backtest.param_grid
# Prewarm the disk cache once before fanning out — avoids each worker
# re-fetching from Twelve Data.
if prewarm_cache:
logger.info("Prewarming disk cache for backtest data...")
warmer = WalkForwardBacktester(self.config)
warmer.load_data()
keys = list(param_grid.keys())
value_lists = [param_grid[k] for k in keys]
combos = [
dict(zip(keys, vals))
for vals in itertools.product(*value_lists)
]
n_workers = min(
self.config.backtest.parallel_workers,
len(combos),
os.cpu_count() or 1,
)
logger.info(
f"Optimizer: {len(combos)} combos × {n_workers} workers"
)
tasks = [(self.config, c) for c in combos]
if n_workers <= 1:
results = [_run_single_combo(t) for t in tasks]
else:
with mp.Pool(processes=n_workers) as pool:
results = pool.map(_run_single_combo, tasks)
# Sort by total return descending
results.sort(
key=lambda r: r.metrics.get("total_return_pct", -float("inf")),
reverse=True,
)
return results