Provider abstraction: providers/ package with BaseDataProvider template

One class per provider (TwelveData, Oanda, Tiingo), all subclassing
BaseDataProvider which owns cache, validation/cleaning, freshness +
boundary guarantees, fetch_range pagination, and streaming stubs —
subclasses implement only _fetch_raw. config.json data_providers.active
selects the live feed; data_provider.py / oanda_provider.py stay as
back-compat shims. Module 16 provider-abstraction tests (253 total green).

Claude-Session: https://claude.ai/code/session_01Mc7XKYyPySVXiUpcN82QEK
This commit is contained in:
Harwinder Sekhon
2026-07-28 20:09:00 -04:00
parent f760e9070a
commit 083a4e8d63
17 changed files with 1573 additions and 935 deletions
+11 -67
View File
@@ -55,7 +55,7 @@ from config import ( # noqa: E402
Timeframe,
XAUUSD,
)
from data_provider import ForexDataProvider, make_data_provider # noqa: E402
from providers import BaseDataProvider, make_data_provider # noqa: E402
from engine import IndicatorSignal, TechnicalAnalysis, TechnicalEngine # noqa: E402
from signal_aggregator import SignalAggregator, SignalReport, TradeSetup # noqa: E402
@@ -941,7 +941,7 @@ def _cache_path(symbol: str, tf: Timeframe, start: datetime, end: datetime) -> P
def fetch_history(
provider: ForexDataProvider,
provider: BaseDataProvider,
symbol: str,
timeframe: Timeframe,
start: datetime,
@@ -949,12 +949,13 @@ def fetch_history(
use_cache: bool = True,
clean: bool = True,
) -> pd.DataFrame:
"""Fetch OHLCV between start/end with parquet caching and pagination.
"""Fetch OHLCV between start/end with parquet caching.
Twelve Data caps responses at 5000 bars; we paginate by date range so
multi-month M5/M15 backtests work.
Pagination under the provider's per-request bar cap lives in
`BaseDataProvider.fetch_range`, so any registered provider can feed
the backtester.
The parquet cache stores the RAW series; `_validate_and_clean` runs on
The parquet cache stores the RAW series; `validate_and_clean` runs on
load. Pass clean=False to get the raw price path — trade resolution must
use it, because cleaning drops >10σ spike bars (news candles) that hit
stops in reality. (Caches written before this change already contain
@@ -967,73 +968,16 @@ def fetch_history(
df = pd.read_parquet(path)
if df.index.tz is None:
df.index = df.index.tz_localize("UTC")
return provider._validate_and_clean(df, symbol, timeframe) if clean else df
return provider.validate_and_clean(df, symbol, timeframe) if clean else df
logger.info(f"Fetching {symbol} {timeframe.value} from {start.date()} to {end.date()}")
# Bars-per-call estimate to size each window safely under the 5000 cap.
bars_per_day = ForexDataProvider.BARS_PER_DAY[timeframe]
days_per_chunk = max(1, int(4500 / bars_per_day))
chunks: List[pd.DataFrame] = []
cursor_end = end
empty_streak = 0
while cursor_end > start:
cursor_start = max(start, cursor_end - timedelta(days=days_per_chunk))
try:
chunk = provider._fetch_twelve_data(
symbol=symbol,
timeframe=timeframe,
outputsize=5000,
start_date=cursor_start.strftime("%Y-%m-%d %H:%M:%S"),
end_date=cursor_end.strftime("%Y-%m-%d %H:%M:%S"),
)
except Exception as e:
# Twelve Data raises BadRequest for windows with no data (weekends,
# holidays, asset gaps). Skip the window, step back, retry — but
# bail out after a few consecutive empties to avoid spinning.
logger.warning(
f"Chunk {cursor_start.date()}–{cursor_end.date()} failed: {e}"
)
empty_streak += 1
if empty_streak >= 3:
break
cursor_end = cursor_start - timedelta(seconds=1)
continue
if chunk is None or chunk.empty:
empty_streak += 1
if empty_streak >= 3:
break
cursor_end = cursor_start - timedelta(seconds=1)
continue
empty_streak = 0
chunks.append(chunk)
# Step back; dedup on concat.
cursor_end = chunk.index[0] - timedelta(seconds=1)
if len(chunk) < 2:
break
if not chunks:
raise RuntimeError(f"No data fetched for {symbol} {timeframe.value}")
df = pd.concat(chunks).sort_index()
df = df[~df.index.duplicated(keep="last")]
# Fetches ending "now" include the still-forming candle — drop it so the
# cache only ever contains closed bars.
df = provider._drop_forming_candle(df, timeframe)
# Trim to requested window. `start`/`end` are already tz-aware UTC
# datetimes — wrapping with pd.Timestamp(..., tz=...) would raise on
# already-localized inputs.
end_ts = pd.Timestamp(end)
start_ts = pd.Timestamp(start)
df = df[(df.index >= start_ts) & (df.index <= end_ts)]
df = provider.fetch_range(symbol, timeframe, start, end)
if use_cache:
df.to_parquet(path)
logger.info(f"Cached {len(df)} raw bars to {path.name}")
return provider._validate_and_clean(df, symbol, timeframe) if clean else df
return provider.validate_and_clean(df, symbol, timeframe) if clean else df
# ═════════════════════════════════════════════════════════════════════
@@ -1703,7 +1647,7 @@ def run_backtest(
# ≥210 bars (outputsize floor). Target data_provider's min_required
# (210 bars); ×7/5 converts trading days to calendar days. Intraday
# TFs stay on the base pad, so their books are unchanged.
bars_per_day = ForexDataProvider.BARS_PER_DAY[tf]
bars_per_day = BaseDataProvider.BARS_PER_DAY[tf]
min_pad_days = int(np.ceil(210 / bars_per_day * 7 / 5)) + 5
tf_start = bt_cfg.start - timedelta(days=max(pad_days, min_pad_days))
df = fetch_history(