e30535f5e9
## 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. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
668 lines
27 KiB
Python
668 lines
27 KiB
Python
"""
|
||
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
|