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
This commit is contained in:
Harwinder Sekhon
2026-07-22 17:18:14 -04:00
parent 65a1debc4e
commit f814485615
6 changed files with 733 additions and 27 deletions
+157
View File
@@ -0,0 +1,157 @@
"""
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}")