91b4a6bb5e
- 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. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HVRmy6KspMs96o5PTPYFAB
158 lines
5.8 KiB
Python
158 lines
5.8 KiB
Python
"""
|
||
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}")
|