Files
ForexBot2/backtest.py
T
Harwinder 7699e6dd1d Add indicator ablation framework + apply ablation-driven cleanup
Framework:
- TechnicalConfig.disabled_indicators skips named indicators in engine.analyze
- Optimizer supports disable_indicator override for parallel ablation sweeps
- main.py --ablate runs leave-one-out across all 10 directional indicators

Cleanup applied (validated across 2024 trend / 2023 chop / 2026 recent):
- Drop pivot by default (cumulative +9.5% to disable)
- Drop candlestick by default (+3.4% cumulative)
- Regime-gate ichimoku: 1.3x in trending, 0.2x in ranging (was a chop villain)
- Trim RSI weight 1.0 -> 0.3 (cumulative noise across regimes)

Result: +10.60% on 2024 trend (+33.24% -> +43.84%, Sharpe 2.17 -> 2.77),
+15.71% on 2023 chop (-18.27% -> -2.56%). Recent regime still adverse.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 17:33:07 -04:00

518 lines
20 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 dataclasses import replace
from typing import 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 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)
bt = self.config.backtest
balance = bt.initial_balance
equity_points: List[Tuple[pd.Timestamp, float]] = [
(primary_df.index[warmup], balance)
]
trades: List[Trade] = []
open_position: Optional[Dict] = None
# Optional progress bar
iterator = range(warmup, len(primary_df) - 1)
if progress:
try:
from tqdm import tqdm # type: ignore
iterator = tqdm(iterator, desc="Backtesting", 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))
open_position = None
# ── Step 2: maybe open a new position ──────────────────
if open_position is None or not bt.one_position_only:
# Slice each TF's df up to (and including) bars closed by decision_time
sliced = self._slice_dfs(dfs, decision_time)
report = self.aggregator.analyze_from_dataframes(
symbol=self.config.symbol,
dfs=sliced,
use_news=False,
)
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)
):
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
@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
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,
}
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"],
)
# ══════════════════════════════════════════════════════════════════
# 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 "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