Files
ForexBot2/tiingo_provider.py
T
Harwinder Sekhon f814485615 Tiingo bid/ask spread monitor + logging hardening
- tiingo_provider.py: /tiingo/fx/top quotes (10s TTL cache, graceful
  None on any failure); spread log writer. Signals never consume
  bid/ask; warn-only by design (no historical bid/ask to validate a
  gate against — live == backtest rule).
- app_config: repo-root .env loader (setdefault; shell env still wins),
  tiingo_api_key resolver (TIINGO_API_KEY/TIINGO → config.json),
  telegram.channel_spread_detail flag (default false).
- telegram_bot: 5-min spread sampler → logs/spread_log.csv (weekend-
  aware), alert-time quote refresh, bid/ask + wide-spread warning in
  signal messages, /status spread line. Raw numbers to owner DM always;
  channel only while channel_spread_detail=true (Tiingo free tier is
  internal-use-only; flip before public launch). Free-tier limit math:
  worst case ~41 req/h vs 50/h cap, one pair.
- Logging: 3h rotation x 240 backups (30-day retention), full-date
  stamps, force=True root handlers; candle loops + sampler survive
  failed cycles; breach-task exceptions logged via done-callback;
  PTB error handler; sys/threading excepthooks in main().
- test_all.py: Module 14 (tiingo, dotenv, spread routing, loop
  resilience, rotation) — 215 tests green.

Claude-Session: https://claude.ai/code/session_01HVRmy6KspMs96o5PTPYFAB
2026-07-22 17:18:14 -04:00

158 lines
5.8 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.
"""
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}")