Files
ForexBot2/providers/tiingo.py
T
Harwinder Sekhon f0d76021d1 Tiingo WebSocket streaming: re-enable the level-breach monitor
The monitor has been dead since the Oanda feed switch (Oanda's provider
does not stream). New config.json scalar data_providers.streaming selects
a tick-stream provider independent of the candle feed; telegram_bot
resolves it via _get_streaming_dp() (separate instance, candle cache
stays on the active provider) and falls back to the active provider when
unset — behavior is byte-identical to before when the key is absent.

TiingoDataProvider grows the two sanctioned streaming overrides:
supports_streaming=True and iter_ticks() on wss://api.tiingo.com/fx
(mid price only — the no-spread rule extends to the stream). Two
deliberate divergences from the TD reference implementation, both
because Tiingo authenticates inside the socket: server "E" messages
raise TiingoDataError (a swallowed auth error would end the generator
cleanly and reconnect-hammer with zero backoff), and on_connect fires on
the first received message rather than after the subscribe send. Field
order of quote frames was live-verified: ask is data[7], not data[6] as
the docs table suggests (data[6] is askSize).

make_data_provider(config, name=None) now takes an explicit provider
name; keyless streaming providers disable the monitor with one warning
instead of a traceback-per-minute loop.

Tests 266 → 273 (WS parser, ticker round-trip, streaming config scalar,
explicit-name dispatch, streaming-dp resolution, monitor auto-disable).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nsd8HcrV67GbCZXxZEn1Ma
2026-08-03 13:11:36 -04:00

307 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
ForexBot2 — Tiingo data provider
=================================
OHLCV candles from Tiingo's FX endpoint
(`/tiingo/fx/{ticker}/prices`, resampled mid-price bars, no volume).
All shared behavior (cache, validation, boundary guarantees, range
pagination) lives in `BaseDataProvider`; this module only maps
symbols/resample frequencies, sizes the request window, and parses
Tiingo's JSON into the normalized frame.
Every failure mode raises `TiingoDataError` (a `DataIntegrityError`)
with a specific message — the scan loops log it and carry on; nothing
fails silently. NOTE: candles and mid-price ticks only. Tiingo's free
`/tiingo/fx/top` quote endpoint publishes indicative two-tier spreads
and must not be used for spread display (removed 2026-07-23) —
`get_spread` deliberately stays on the base-class default, and the
WebSocket stream (`iter_ticks`) exposes the mid price only, never
bid/ask or a derived spread.
"""
import json
import logging
from datetime import datetime
from typing import AsyncIterator, Callable, Optional, Sequence, Tuple
import pandas as pd
import requests
from app_config import provider_api_key
from config import PlatformConfig, Timeframe
from providers.base import BaseDataProvider, DataIntegrityError
logger = logging.getLogger(__name__)
class TiingoDataError(DataIntegrityError):
"""Raised when a Tiingo data fetch fails or returns invalid data."""
class TiingoDataProvider(BaseDataProvider):
"""Tiingo FX REST candle provider."""
name = "tiingo"
DATA_ERROR = TiingoDataError
BASE_URL = "https://api.tiingo.com"
TIINGO_RESAMPLE = {
Timeframe.M1: "1min",
Timeframe.M5: "5min",
Timeframe.M15: "15min",
Timeframe.M30: "30min",
Timeframe.H1: "1hour",
Timeframe.H4: "4hour",
Timeframe.D1: "1day",
# W1 intentionally unmapped: the FX endpoint has no weekly
# resample — requesting it raises loudly instead of guessing.
}
TIINGO_TICKER = {
"XAU/USD": "xauusd",
}
# Window padding over the theoretical bar span: FX is closed ~2/7 of
# the week (Fri 22:00 → Sun 22:00 UTC), so a calendar window must be
# wider than `bars × candle_seconds` to yield the requested count.
WINDOW_PAD_FACTOR = 1.6
def __init__(self, config: PlatformConfig, api_key: Optional[str] = None):
super().__init__(config)
# Credentials default to env TIINGO_API_KEY → config.json
# data_providers.tiingo.api_key, so the provider registry can
# build this class with config only.
if api_key is None:
api_key = provider_api_key(provider="tiingo")
self._api_key = api_key or None
if self._api_key is None:
logger.warning(
"Tiingo provider built WITHOUT an API key — every fetch "
"will fail with a named error. Set the TIINGO_API_KEY env "
"var or data_providers.tiingo.api_key in config.json.")
self._session = requests.Session()
self._session.headers.update({
"Content-Type": "application/json",
# Header auth keeps the key out of URLs (and out of logs).
"Authorization": f"Token {api_key}" if api_key else "",
})
def _to_tiingo_ticker(self, symbol: str) -> str:
if symbol in self.TIINGO_TICKER:
return self.TIINGO_TICKER[symbol]
return symbol.replace("/", "").replace("=X", "").lower()
# ── Raw fetch ──────────────────────────────────────────────────
def _fetch_raw(
self,
symbol: str,
timeframe: Timeframe,
outputsize: int,
start: Optional[datetime] = None,
end: Optional[datetime] = None,
) -> pd.DataFrame:
if self._api_key is None:
raise TiingoDataError(
"No Tiingo API key. Set the TIINGO_API_KEY env var or "
"data_providers.tiingo.api_key in config.json.")
resample = self.TIINGO_RESAMPLE.get(timeframe)
if resample is None:
raise TiingoDataError(
f"Unsupported timeframe {timeframe} for Tiingo "
f"(no FX resample frequency)")
ticker = self._to_tiingo_ticker(symbol)
# Tiingo takes date windows, not bar counts: derive the start
# from the requested count for live fetches.
if start is None:
span = self.CANDLE_SECONDS[timeframe] * (outputsize + 10)
start_ts = (pd.Timestamp.now(tz="UTC")
- pd.Timedelta(seconds=span * self.WINDOW_PAD_FACTOR))
else:
start_ts = pd.Timestamp(start)
params = {
"resampleFreq": resample,
"startDate": start_ts.strftime("%Y-%m-%dT%H:%M:%S"),
}
if end is not None:
params["endDate"] = pd.Timestamp(end).strftime("%Y-%m-%dT%H:%M:%S")
url = f"{self.BASE_URL}/tiingo/fx/{ticker}/prices"
try:
resp = self._session.get(url, params=params, timeout=30)
except requests.RequestException as e:
raise TiingoDataError(
f"Tiingo candle fetch failed for {ticker}: {e}") from e
if resp.status_code == 429:
raise TiingoDataError(
f"Tiingo rate limit hit for {ticker} (HTTP 429) — "
f"plan quota exhausted; scan will retry next boundary")
if resp.status_code in (401, 403):
raise TiingoDataError(
f"Tiingo auth failed for {ticker} (HTTP {resp.status_code}) "
f"— check TIINGO_API_KEY / config.json api_key")
if resp.status_code != 200:
raise TiingoDataError(
f"Tiingo candle fetch failed for {ticker}: "
f"HTTP {resp.status_code}: {resp.text[:300]}")
try:
data = resp.json()
except ValueError as e:
raise TiingoDataError(
f"Tiingo candle response for {ticker} is not valid "
f"JSON: {e}") from e
if not isinstance(data, list) or not data:
raise TiingoDataError(
f"Empty candle response from Tiingo for {ticker} "
f"(window {params['startDate']} → "
f"{params.get('endDate', 'now')}; check ticker and plan "
f"history limits)")
rows = []
for item in data:
if "date" not in item:
raise TiingoDataError(
f"Malformed Tiingo candle for {ticker}: missing "
f"'date' in {str(item)[:120]}")
rows.append({
"Datetime": pd.Timestamp(item["date"]),
"Open": item.get("open"),
"High": item.get("high"),
"Low": item.get("low"),
"Close": item.get("close"),
# FX endpoint has no volume; 0.0 keeps the engine contract.
"Volume": 0.0,
})
df = pd.DataFrame(rows)
df = df.set_index("Datetime")
df = df.sort_index()
if df.index.tz is None:
df.index = df.index.tz_localize("UTC")
else:
df.index = df.index.tz_convert("UTC")
df.index.name = "Datetime"
for col in ("Open", "High", "Low", "Close"):
df[col] = pd.to_numeric(df[col], errors="coerce")
if df["Close"].isna().all():
raise TiingoDataError(
f"Tiingo response for {ticker} has no numeric closes — "
f"feed format changed?")
logger.debug(
"Fetched %d bars for %s @ %s from Tiingo",
len(df), ticker, resample)
return df
# ── Streaming quotes ───────────────────────────────────────────
supports_streaming = True
WS_URL = "wss://api.tiingo.com/fx"
@staticmethod
def _parse_ws_message(raw: str) -> Optional[Tuple[str, float]]:
"""Parse one WS message; return (tiingo_ticker, mid_price) for
quote updates, None for heartbeats ("H"), subscription acks
("I"), malformed JSON, or quotes without a usable price.
Server error messages ("E", e.g. bad token) RAISE TiingoDataError:
Tiingo authenticates inside the socket via the subscribe message,
so an auth failure arrives as a data message followed by a clean
close — swallowing it would make the caller's reconnect loop
hammer the endpoint with no backoff (backoff only grows on
exceptions).
"""
try:
msg = json.loads(raw)
except json.JSONDecodeError:
logger.warning("Tiingo WS: malformed JSON, skipping")
return None
mtype = msg.get("messageType")
if mtype == "H":
return None
if mtype == "I":
logger.info("Tiingo WS: subscription acknowledged")
return None
if mtype == "E":
raise TiingoDataError(
f"Tiingo WS server error: {str(msg.get('response'))[:200]}")
if mtype != "A":
return None
data = msg.get("data")
if not isinstance(data, list) or len(data) < 8 or data[0] != "Q":
return None
# Live-verified field order (2026-08-03): ["Q", ticker, timestamp,
# bidSize, bid, mid, askSize, ask] — ask is index 7, NOT 6 as the
# docs' table suggests (index 6 is askSize).
ticker = data[1]
bid, mid, ask = data[4], data[5], data[7]
if mid is None and bid is not None and ask is not None:
mid = (bid + ask) / 2
try:
return ticker, float(mid)
except (TypeError, ValueError):
logger.warning("Tiingo WS: quote for %s has no usable price",
ticker)
return None
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, mid) ticks.
The caller owns the reconnect/backoff loop around this generator.
Divergences from the Twelve Data reference implementation, both
deliberate: `on_connect` fires on the FIRST RECEIVED message (the
"I" ack in practice) rather than after the subscribe send, so a
rejected token cannot reset the caller's backoff; and there is no
client heartbeat task — Tiingo sends "H" frames itself and the
websockets library's built-in ping keepalive detects dead sockets.
"""
import websockets
if self._api_key is None:
raise TiingoDataError(
"No Tiingo API key. Set the TIINGO_API_KEY env var or "
"data_providers.tiingo.api_key in config.json.")
ws_to_pair = {self._to_tiingo_ticker(s): s for s in symbols}
async with websockets.connect(self.WS_URL) as ws:
await ws.send(json.dumps({
"eventName": "subscribe",
"authorization": self._api_key,
"eventData": {
"thresholdLevel": 5,
"tickers": sorted(ws_to_pair),
},
}))
logger.info(
"Tiingo WS connected, subscribing to %d tickers",
len(ws_to_pair))
connected = False
async for raw in ws:
parsed = self._parse_ws_message(raw)
if not connected:
connected = True
if on_connect is not None:
on_connect()
if parsed is None:
continue
pair_symbol = ws_to_pair.get(parsed[0])
if pair_symbol is None:
continue
yield pair_symbol, parsed[1]