Files
ForexBot2/data_provider.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

399 lines
14 KiB
Python

"""
ForexBot2 — Data Provider (Twelve Data SDK, USD/JPY only)
==========================================================
Fetches and validates OHLCV via the official `twelvedata` Python SDK.
No synthetic fallback — if the API fails the caller learns about it.
Adds a parquet on-disk cache so the backtest worker pool reuses one
download instead of hammering Twelve Data during a parameter sweep.
"""
import logging
import os
import time
from collections import deque
from typing import Dict, Optional
import pandas as pd
from twelvedata import TDClient
from config import PlatformConfig, Timeframe, USDJPY, DISPLAY, PIP_VALUE
logger = logging.getLogger(__name__)
class DataIntegrityError(Exception):
"""Raised when data fails validation checks."""
pass
class ForexDataProvider:
"""Twelve Data SDK client + on-disk parquet cache."""
# Local MetaTrader CSV exports — tab-delimited, 7 columns (includes Spread)
LOCAL_CSV_MAP = {
Timeframe.M15: "USDJPY_M15.csv",
Timeframe.H1: "USDJPY_H1.csv",
Timeframe.H4: "USDJPY_H4.csv",
Timeframe.D1: "USDJPY_D1.csv",
}
TD_INTERVALS = {
Timeframe.M1: "1min",
Timeframe.M5: "5min",
Timeframe.M15: "15min",
Timeframe.M30: "30min",
Timeframe.H1: "1h",
Timeframe.H4: "4h",
Timeframe.D1: "1day",
Timeframe.W1: "1week",
}
BARS_PER_DAY = {
Timeframe.M1: 1440,
Timeframe.M5: 288,
Timeframe.M15: 96,
Timeframe.M30: 48,
Timeframe.H1: 24,
Timeframe.H4: 6,
Timeframe.D1: 1,
Timeframe.W1: 0.143,
}
# Free plan: 8 credits / minute
RATE_LIMIT = 8
RATE_WINDOW = 60.0
def __init__(self, config: PlatformConfig):
self.config = config
self._api_key = (
os.environ.get("TWELVE_DATA_API_KEY")
or config.twelve_data_api_key
)
self._cache: Dict[str, pd.DataFrame] = {}
self._cache_timestamps: Dict[str, float] = {}
self._request_times: deque = deque()
self._td: Optional[TDClient] = (
TDClient(apikey=self._api_key) if self._api_key else None
)
@staticmethod
def _to_td_symbol(symbol: str) -> str:
clean = symbol.replace("=X", "")
return f"{clean[:3]}/{clean[3:]}"
# ── Public API ─────────────────────────────────────────────────
def fetch_ohlcv(
self,
symbol: str = USDJPY,
timeframe: Timeframe = Timeframe.H1,
lookback_days: Optional[int] = None,
) -> pd.DataFrame:
"""Fetch OHLCV with in-memory caching for live mode."""
if lookback_days is None:
lookback_days = self.config.data_lookback_days
cache_key = f"{symbol}_{timeframe.value}"
now = time.time()
if (
cache_key in self._cache_timestamps
and (now - self._cache_timestamps[cache_key]) < self.config.refresh_interval
and cache_key in self._cache
):
cached = self._cache.get(cache_key)
if cached is not None and not cached.empty:
return cached
outputsize = max(
300,
min(int(lookback_days * self.BARS_PER_DAY[timeframe]) + 20, 5000),
)
df = self._fetch_twelve_data(symbol, timeframe, outputsize)
df = self._validate_and_clean(df, symbol, timeframe)
self._cache[cache_key] = df
self._cache_timestamps[cache_key] = now
return df
def fetch_full_history(
self,
symbol: str = USDJPY,
timeframe: Timeframe = Timeframe.H1,
max_bars: int = 5000,
use_disk_cache: bool = True,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
) -> pd.DataFrame:
"""
Pull the largest window Twelve Data allows in one call.
On-disk parquet cache prevents re-download across worker subprocesses
during an optimizer sweep.
"""
cache_path = self._disk_cache_path(symbol, timeframe, start_date, end_date)
if use_disk_cache and os.path.exists(cache_path):
try:
df = pd.read_parquet(cache_path)
if df.index.tz is None:
df.index = df.index.tz_localize("UTC")
logger.info(
f"Loaded {len(df)} bars from disk cache: {cache_path}"
)
return df
except Exception as e:
logger.warning(f"Disk cache read failed ({e}); refetching.")
outputsize = min(max_bars, 5000)
df = self._fetch_twelve_data(
symbol, timeframe, outputsize,
start_date=start_date, end_date=end_date,
)
df = self._validate_and_clean(df, symbol, timeframe)
if use_disk_cache:
os.makedirs(os.path.dirname(cache_path), exist_ok=True)
try:
df.to_parquet(cache_path)
logger.info(f"Cached {len(df)} bars → {cache_path}")
except Exception as e:
logger.warning(f"Disk cache write failed: {e}")
return df
def load_local_csv(
self,
timeframe: Timeframe,
data_dir: str = "market_data",
start_date: Optional[str] = None,
end_date: Optional[str] = None,
) -> Optional[pd.DataFrame]:
"""
Load OHLCV from a local MetaTrader CSV export.
Format: tab-separated, columns Time/Open/High/Low/Close/Volume/Spread.
Returns None silently if the file doesn't exist for this timeframe.
"""
fname = self.LOCAL_CSV_MAP.get(timeframe)
if fname is None:
logger.info(f"No local CSV mapping for {timeframe.value} — skipping")
return None
path = os.path.join(data_dir, fname)
if not os.path.exists(path):
logger.warning(f"Local CSV not found: {path}")
return None
df = pd.read_csv(
path, sep="\t",
names=["Time", "Open", "High", "Low", "Close", "Volume", "Spread"],
header=0,
parse_dates=["Time"],
)
df = df.set_index("Time")
df = df.drop(columns=["Spread"], errors="ignore")
for col in ("Open", "High", "Low", "Close", "Volume"):
if col in df.columns:
df[col] = pd.to_numeric(df[col], errors="coerce")
if "Volume" not in df.columns:
df["Volume"] = 0.0
if df.index.tz is None:
df.index = df.index.tz_localize("UTC")
df.index.name = "Datetime"
if start_date:
df = df[df.index >= pd.Timestamp(start_date, tz="UTC")]
if end_date:
df = df[df.index <= pd.Timestamp(end_date, tz="UTC")]
df = self._validate_and_clean(df, USDJPY, timeframe)
logger.info(f"Loaded {len(df):,} bars from local CSV: {path}")
return df
def get_latest_price(self, symbol: str = USDJPY) -> Optional[float]:
try:
df = self.fetch_ohlcv(symbol, Timeframe.M5, lookback_days=1)
if df is not None and not df.empty:
return float(df["Close"].iloc[-1])
except Exception:
pass
return None
def get_spread_estimate(self, symbol: str = USDJPY) -> float:
"""Estimate spread from recent M5 H-L median; fallback to 1 pip on JPY."""
try:
df = self.fetch_ohlcv(symbol, Timeframe.M5, lookback_days=1)
if df is not None and not df.empty:
return float((df["High"] - df["Low"]).median())
except Exception:
pass
# Fallback: 1 pip = 0.01 for JPY pairs
return PIP_VALUE
def get_pair_info(self, symbol: str = USDJPY) -> Dict:
return {
"symbol": symbol,
"display": DISPLAY,
"pip_value": PIP_VALUE,
"is_jpy_pair": True,
}
# ── Private ────────────────────────────────────────────────────
def _disk_cache_path(
self,
symbol: str,
timeframe: Timeframe,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
) -> str:
clean = symbol.replace("=X", "").lower()
suffix = ""
if start_date or end_date:
suffix = f"_{start_date or 'min'}_{end_date or 'max'}"
return os.path.join(
self.config.backtest.cache_dir,
f"{clean}_{timeframe.value}{suffix}.parquet",
)
def _throttle(self, credits: int = 1) -> None:
"""Free tier: 8 credits / minute. Block until budget allows."""
while True:
now = time.time()
while self._request_times and now - self._request_times[0] >= self.RATE_WINDOW:
self._request_times.popleft()
available = self.RATE_LIMIT - len(self._request_times)
if available >= credits:
for _ in range(credits):
self._request_times.append(time.time())
return
wait = (self._request_times[0] + self.RATE_WINDOW) - now + 0.2
logger.info(
f"Rate limit: {len(self._request_times)}/{self.RATE_LIMIT} credits used "
f"— waiting {wait:.1f}s..."
)
time.sleep(wait)
def _fetch_twelve_data(
self,
symbol: str,
timeframe: Timeframe,
outputsize: int,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
) -> pd.DataFrame:
if self._td is None:
raise DataIntegrityError(
"No Twelve Data API key. Set TWELVE_DATA_API_KEY env var "
"or config.twelve_data_api_key."
)
td_symbol = self._to_td_symbol(symbol)
interval = self.TD_INTERVALS[timeframe]
self._throttle(credits=1)
kwargs = {
"symbol": td_symbol,
"interval": interval,
"outputsize": min(outputsize, 5000),
"timezone": "UTC",
}
if start_date:
kwargs["start_date"] = start_date
if end_date:
kwargs["end_date"] = end_date
try:
df = self._td.time_series(**kwargs).as_pandas()
except Exception as e:
raise DataIntegrityError(
f"Twelve Data fetch failed for {td_symbol}: {e}"
) from e
if df is None or df.empty:
raise DataIntegrityError(f"Empty response from Twelve Data for {td_symbol}")
# SDK returns lowercase columns; engine expects capitalised.
df = df.rename(columns={
"open": "Open", "high": "High", "low": "Low",
"close": "Close", "volume": "Volume",
})
for col in ("Open", "High", "Low", "Close", "Volume"):
if col in df.columns:
df[col] = pd.to_numeric(df[col], errors="coerce")
if "Volume" not in df.columns:
df["Volume"] = 0.0
# SDK returns newest-first; engine + indicators want oldest-first.
df = df.sort_index()
if df.index.tz is None:
df.index = df.index.tz_localize("UTC")
df.index.name = "Datetime"
logger.debug(f"Fetched {len(df)} bars for {td_symbol} @ {interval}")
return df
def _validate_and_clean(
self, df: pd.DataFrame, symbol: str, timeframe: Timeframe
) -> pd.DataFrame:
if df is None or df.empty:
raise DataIntegrityError(f"Empty dataset for {symbol}")
original_len = len(df)
df = df.dropna(subset=["Open", "High", "Low", "Close"], how="all")
df = df.ffill(limit=3)
df = df.dropna(subset=["Close"])
now = pd.Timestamp.utcnow()
future_mask = df.index > now + pd.Timedelta(minutes=5)
if future_mask.any():
logger.warning(f"{symbol}: Dropping {future_mask.sum()} future timestamps")
df = df[~future_mask]
invalid_ohlc = (
(df["High"] < df["Low"]) |
(df["High"] < df["Open"]) |
(df["High"] < df["Close"]) |
(df["Low"] > df["Open"]) |
(df["Low"] > df["Close"])
)
if invalid_ohlc.any():
logger.warning(f"{symbol}: Fixing {invalid_ohlc.sum()} OHLC-inconsistent bars")
for idx in df[invalid_ohlc].index:
vals = [df.loc[idx, "Open"], df.loc[idx, "Close"]]
df.loc[idx, "High"] = max(vals + [df.loc[idx, "High"]])
df.loc[idx, "Low"] = min(vals + [df.loc[idx, "Low"]])
returns = df["Close"].pct_change()
std_ret = returns.std()
if std_ret > 0:
outlier_mask = (returns - returns.mean()).abs() > 10 * std_ret
if outlier_mask.any():
logger.warning(f"{symbol}: Removing {outlier_mask.sum()} extreme outlier bars")
df = df[~outlier_mask]
min_required = max(
self.config.technical.ema_trend,
self.config.technical.ichimoku_senkou_b,
200,
) + 10
if len(df) < min_required:
logger.warning(
f"{symbol} {timeframe.value}: Only {len(df)} bars "
f"(need {min_required}). Some indicators will be partial."
)
df = df.sort_index()
df = df[~df.index.duplicated(keep="last")]
cleaned = original_len - len(df)
if cleaned > 0:
logger.info(f"{symbol}: Cleaned {cleaned}/{original_len} bars")
return df