Files
ForexBot2/providers/twelve_data.py
T
Harwinder Sekhon 99eb268cf3 audit: alert delivery, provider robustness, backtester NaN/cache guards (Batches C+E+F)
2026-08-04 Fable 5 QA audit. Three batches sharing one test file, all
verified by runnable repros before fixing, all gated below.

Batch C — telegram alert delivery (verified by h4 harness):
- Failed breach rescan shrinks the cooldown to 30 s instead of
  silencing the symbol for 5 min; breach during cooldown is PARKED and
  fired on the first tick after expiry (was dropped forever).
- Level cache keyed symbol→TF (M5 scans no longer wipe H1/H4 swing
  levels within minutes); breach labels TF-qualified.
- Markdown alert sends get a plain-text fallback (mirroring _safe_edit);
  fingerprint commits only when a message actually landed.
- Per-key asyncio.Lock closes the dedup TOCTOU between auto-scan and
  breach sends (forced-interleave repro produced duplicate alerts).
- _start_scheduler respawns dead per-TF loops (was all-or-nothing).
- Clean WS close now backs off (was a zero-delay reconnect hot loop);
  backoff resets only after a connection that received ticks; TD
  iter_ticks fires on_connect on the first RECEIVED frame (invalid key
  can no longer pin the backoff at minimum).
- Scan-failure logs carry the real traceback (was "NoneType: None").

Batch E — providers (verified by h5 harness):
- fetch_range: 3 consecutive chunk ERRORS raise instead of returning
  silently truncated history (verified 731-day silent gap repro; also
  covers 429 storms); pagination no-progress guard; head-truncation
  ERROR log.
- Single-flight per (symbol,TF): 4 concurrent scans = 1 API call (was
  4); cache records requested depth so a longer-lookback call refetches.
- Multi-bar forming/future tail fully dropped (was iloc[:-1] once).
- Oanda: malformed candles skipped, never 0.0-defaulted; cleaning drops
  non-positive-price rows BEFORE OHLC repair; inf returns neutralized so
  one corrupt bar can no longer disarm the whole 10-sigma filter.

Batch F — backtester internals (verified by h6 harness):
- NaN bar in a trailing window exits at the last valid bar (was NaN R
  poisoning every metric); static-path TIMEOUT NaN guard;
  compute_metrics excludes non-finite R loudly. Zero real-data exposure
  (0 NaN rows in all 10 Oanda parquets) — defensive.
- fetch_history ERROR-logs pre-2026-07-08 cache files (cleaned-bars era)
  when loaded for raw-path resolution.

Gates: suite 333/333 green; pinned M15 12mo book reproduced identically
(397 trades, +78.7111R) after E and after F; M5 HEAD-config baseline
reproduced identically (556 trades, +97.5604R) after F.

Claude-Session: https://claude.ai/code/session_01UnZkzeFcTu9agFg8BDh2tn
2026-08-04 20:02:57 -04:00

248 lines
8.7 KiB
Python

"""
ForexBot2 — Twelve Data provider
=================================
OHLCV via the official `twelvedata` SDK plus streaming quotes over the
Twelve Data WebSocket. All shared behavior (cache, validation, boundary
guarantees, range pagination) lives in `BaseDataProvider`.
"""
import asyncio
import json
import logging
import threading
import time
from collections import deque
from datetime import datetime
from typing import AsyncIterator, Callable, Optional, Sequence, Tuple
import pandas as pd
from twelvedata import TDClient
from app_config import provider_api_key
from config import PAIR_INFO, PlatformConfig, Timeframe
from providers.base import BaseDataProvider, DataIntegrityError
logger = logging.getLogger(__name__)
class TwelveDataProvider(BaseDataProvider):
"""Twelve Data SDK client with credit throttling and WS quotes."""
name = "twelve_data"
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",
}
RATE_LIMIT = 55
RATE_WINDOW = 60.0
WS_URL = "wss://ws.twelvedata.com/v1/quotes/price?apikey={api_key}"
def __init__(self, config: PlatformConfig):
super().__init__(config)
self._api_key = provider_api_key(provider="twelve_data") or None
self._throttle_lock = threading.Lock()
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:
if "/" in symbol:
return symbol
clean = symbol.replace("=X", "")
return f"{clean[:3]}/{clean[3:]}"
# ── Raw fetch ──────────────────────────────────────────────────
def _fetch_raw(
self,
symbol: str,
timeframe: Timeframe,
outputsize: int,
start: Optional[datetime] = None,
end: Optional[datetime] = None,
) -> pd.DataFrame:
return self._fetch_twelve_data(
symbol, timeframe, outputsize,
start_date=start.strftime("%Y-%m-%d %H:%M:%S") if start else None,
end_date=end.strftime("%Y-%m-%d %H:%M:%S") if end else None,
)
def _throttle(self, credits: int = 1) -> None:
"""Pro tier: 55 credits / minute. Block until budget allows."""
while True:
with self._throttle_lock:
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
used = len(self._request_times)
wait = (self._request_times[0] + self.RATE_WINDOW) - now + 0.2
logger.info(
f"Rate limit: {used}/{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 data-provider API key. Set the TWELVE_DATA_API_KEY env "
"var or data_providers.twelve_data.api_key in config.json."
)
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, self.MAX_BARS_PER_REQUEST),
"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
# ── Streaming quotes ───────────────────────────────────────────
supports_streaming = True
@staticmethod
def _parse_ws_price(raw: str) -> Optional[Tuple[str, float]]:
"""Parse one WS message; return (ws_symbol, price) for price
events, None for everything else (logged where noteworthy)."""
try:
msg = json.loads(raw)
except json.JSONDecodeError:
logger.warning("WebSocket: malformed JSON, skipping")
return None
event = msg.get("event")
if event == "subscribe-status":
fails = msg.get("fails") or []
if fails:
logger.warning(f"WebSocket: failed to subscribe: {fails}")
return None
if event != "price":
return None
ws_symbol = msg.get("symbol")
try:
price = float(msg["price"])
except (KeyError, ValueError, TypeError) as e:
logger.warning(f"WebSocket: bad price data for {ws_symbol}: {e}")
return None
return ws_symbol, price
@staticmethod
async def _ws_heartbeat(ws) -> None:
"""Twelve Data drops idle WS connections; heartbeat every 10s keeps
it alive through quiet periods (weekends, low-liquidity hours)."""
while True:
await asyncio.sleep(10)
await ws.send(json.dumps({"action": "heartbeat"}))
async def iter_ticks(
self,
symbols: Sequence[str],
on_connect: Optional[Callable[[], None]] = None,
) -> AsyncIterator[Tuple[str, float]]:
"""One connection's lifetime of (canonical_symbol, price) ticks.
The TD WS speaks display symbols ("XAU/USD"); ticks are mapped back
to the canonical pair symbols passed in. The caller owns the
reconnect/backoff loop around this generator.
`on_connect` fires on the FIRST RECEIVED frame, not after the
subscribe send (2026-08-04 audit H38b, mirroring the Tiingo
provider) — firing pre-receive let a rejected API key reset the
caller's reconnect backoff on every attempt, pinning it at the
minimum forever.
"""
import websockets
ws_to_pair = {PAIR_INFO[s]["display"]: s for s in symbols}
url = self.WS_URL.format(api_key=self._api_key or "")
async with websockets.connect(url) as ws:
subscribe = {
"action": "subscribe",
"params": {"symbols": ",".join(ws_to_pair)},
}
await ws.send(json.dumps(subscribe))
logger.info(f"WebSocket connected, subscribed to {len(ws_to_pair)} symbols")
hb_task = asyncio.create_task(self._ws_heartbeat(ws))
connected = False
try:
async for raw in ws:
if not connected:
connected = True
if on_connect is not None:
on_connect()
parsed = self._parse_ws_price(raw)
if parsed is None:
continue
pair_symbol = ws_to_pair.get(parsed[0])
if pair_symbol is None:
continue
yield pair_symbol, parsed[1]
finally:
hb_task.cancel()