99eb268cf3
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
248 lines
8.7 KiB
Python
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()
|