""" tiingo_provider.py — Tiingo top-of-book bid/ask quotes ====================================================== Supplementary LIVE-QUOTE provider, NOT an OHLCV source: Twelve Data remains the signal/backtest data provider. Tiingo's forex endpoint (GET /tiingo/fx/top) returns real bid/ask, which mid-price OHLCV cannot provide. Two uses: 1. Spread visibility on alerts — signal messages show the live bid/ask and warn when the spread is ≥ WIDE_SPREAD_MULTIPLE × the assumed round-trip cost (PAIR_INFO ``default_spread``, $0.30 on XAU) that every backtest charges. Warn-only BY DESIGN: no historical bid/ask exists to replay, so a suppressing gate could never be validated (live == backtest rule). This sits in the same safety category as the staleness check. 2. Spread logging (logs/spread_log.csv, 5-min sampler in the bot) — builds the dataset to validate or refute the $0.30 round-trip assumption against reality. Signals NEVER consume bid/ask — indicators stay on mid OHLCV. Every failure path returns None (alerts go out without the spread block rather than being delayed or dropped). Key resolution: env TIINGO_API_KEY (or TIINGO, e.g. from .env) → config.json data_providers.tiingo.api_key → "" (feature disabled). See app_config.tiingo_api_key. """ import logging import os import threading import time from dataclasses import dataclass from datetime import datetime, timezone from typing import Dict, Optional import requests logger = logging.getLogger(__name__) _TIINGO_FX_TOP_URL = "https://api.tiingo.com/tiingo/fx/top" # Spread ≥ this multiple of the pair's assumed round-trip cost is # flagged as "wide" on alerts (2× the $0.30 XAU assumption = $0.60). WIDE_SPREAD_MULTIPLE = 2.0 # A quote younger than this is served from cache — dedupes bursts # (alert refresh right after a sampler tick) without hiding real moves. QUOTE_TTL_SECONDS = 10.0 SPREAD_LOG_PATH = os.path.join( os.path.dirname(os.path.abspath(__file__)), "logs", "spread_log.csv") _CSV_HEADER = "ts_utc,symbol,bid,ask,mid,spread\n" def tiingo_ticker(symbol: str) -> str: """Canonical '"XAU/USD"' form → Tiingo's 'xauusd'.""" return symbol.replace("/", "").lower() @dataclass class Quote: """One top-of-book snapshot. ``spread`` (ask − bid) IS the round-trip cost a market order pays.""" symbol: str # canonical form, e.g. "XAU/USD" bid: float ask: float mid: float spread: float quote_time: Optional[datetime] # venue timestamp (UTC) if provided fetched_at: float # time.time() at fetch def age_seconds(self) -> float: return time.time() - self.fetched_at class TiingoQuoteProvider: """Minimal /tiingo/fx/top client with a short TTL cache. Lock held across the fetch (news_engine convention) so concurrent scan threads share one request instead of racing the API. """ def __init__(self, api_key: str, quote_ttl: float = QUOTE_TTL_SECONDS): self.api_key = api_key self.quote_ttl = quote_ttl self._cache: Dict[str, Quote] = {} self._lock = threading.Lock() def get_quote(self, symbol: str) -> Optional[Quote]: with self._lock: cached = self._cache.get(symbol) if cached is not None and cached.age_seconds() < self.quote_ttl: return cached quote = self._fetch(symbol) if quote is not None: self._cache[symbol] = quote return quote def _fetch(self, symbol: str) -> Optional[Quote]: try: resp = requests.get( _TIINGO_FX_TOP_URL, params={"tickers": tiingo_ticker(symbol), "token": self.api_key}, timeout=10, ) resp.raise_for_status() payload = resp.json() if not isinstance(payload, list) or not payload: logger.warning( f"Tiingo: unexpected payload for {symbol}: {payload!r:.200}") return None row = payload[0] bid, ask = row.get("bidPrice"), row.get("askPrice") if not isinstance(bid, (int, float)) or not isinstance(ask, (int, float)): logger.warning(f"Tiingo: no bid/ask for {symbol}: {row!r:.200}") return None mid = row.get("midPrice") if not isinstance(mid, (int, float)): mid = (bid + ask) / 2 quote_time: Optional[datetime] = None raw_ts = row.get("quoteTimestamp") if isinstance(raw_ts, str): try: quote_time = datetime.fromisoformat( raw_ts.replace("Z", "+00:00")) except ValueError: pass return Quote( symbol=symbol, bid=float(bid), ask=float(ask), mid=float(mid), spread=float(ask) - float(bid), quote_time=quote_time, fetched_at=time.time(), ) except Exception as exc: logger.warning(f"Tiingo quote fetch failed for {symbol}: {exc}") return None def log_quote(quote: Quote, path: str = SPREAD_LOG_PATH) -> None: """Append one row to the spread log (creates dir + header on first write). ~15 KB/day at 5-min sampling — no rotation needed.""" try: os.makedirs(os.path.dirname(path), exist_ok=True) is_new = not os.path.exists(path) with open(path, "a", encoding="utf-8") as fh: if is_new: fh.write(_CSV_HEADER) ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") fh.write( f"{ts},{quote.symbol},{quote.bid:.5f},{quote.ask:.5f}," f"{quote.mid:.5f},{quote.spread:.5f}\n") except OSError as exc: logger.warning(f"Spread log write failed: {exc}")