c00ed4471b
Removed (all recoverable from git history):
- llm_provider_switch_plan.txt: 2026-08-08 DeepSeek/Anthropic multi-provider plan that
was never implemented (llm_client.py is DeepSeek-only, no `anthropic` anywhere)
- docs/superpowers/specs/2026-07-21-centralized-config-design.md: spec of the
config.json migration that merged the same day; its plan sibling was already
deleted in b936d44. app_config's docstring pointer goes with it.
- scripts/compare_oanda_tiingo.py: Phase 0 recent-tail smoke test, superseded by the
Phase 1/2 study scripts (result recorded in the test plan)
- backtest/research/fetch_oanda_m15.py + oanda_td_divergence.py + its by-hour CSV:
the 2026-07-24 Oanda-vs-Twelve-Data study, superseded by the 2026-08-14 feed
divergence report (which keeps a one-line citation)
- backtest/research/burst_profile.py and hour_analysis.py: superseded by burst_map.py
and book_report.py
- backtest/research/panel_payload_drytest_2026-08-15.py: one-off live DeepSeek probe;
its .jsonl evidence stays
Added to git: the three 2026-08-14 research reports (feed divergence, re-pin
verification, signal parity), their CSV, and docs/data_source_change_test_plan.md -
CLAUDE.md cites them as standing evidence and their driver scripts were already
committed. The test plan no longer refers to auto-execution.
Hygiene:
- .gitignore: drop oanda_state.json (daemon removed 2026-09-03) and the vanished
backtest/trailgrid dirs; ignore .coverage
- docs/architecture_diagram.py only writes the PNG when run as a script (it used
to plot on import); test box refreshed to 461 tests / 31 modules; PNG regenerated
(the committed one still drew the removed daemon)
- backtest/instructions_for_backtest.txt: manual-journal head replaced by the
actual reporting checklist; the 2026-09-06 addendum is unchanged
- doc drift: README/publisher list all six published files; news_summary.php says
4 h (not 24 h) and drops the pre-id sha1 fallback whose window closed;
analyze_signal_log lists six lenses and --scan-outcomes; agent_panel cost model
says 4 requests by default; tiingo comment cites the NY-anchored weekend;
Module 21 title no longer says "execution lifecycle"
Suite: 461 passed, 0 failed.
Claude-Session: https://claude.ai/code/session_0156YhogLytzj9P2iH1skGbL
310 lines
12 KiB
Python
310 lines
12 KiB
Python
"""
|
||
ForexBot2 — Tiingo data provider
|
||
=================================
|
||
OHLCV candles from Tiingo's FX endpoint
|
||
(`/tiingo/fx/{ticker}/prices`, resampled mid-price bars, no volume).
|
||
Tiingo is streaming-only in production: its REST candles remain available
|
||
for comparison research, but it is not a designated candle fallback.
|
||
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 17:00 → Sun 18:00 New York; see providers/base.py), 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]
|