Remove Tiingo spread monitor (indicative two-tier feed, unvalidatable)

Tiingo's free /tiingo/fx/top publishes only a two-tier indicative
spread for gold ($0.10 liquid hours / $0.70 rollover+news; verified on
273 production samples + live probes) — mid ± a posted increment, not
executable depth, so it can never validate the $0.30 backtest cost
assumption. Removed one day after shipping: tiingo_provider.py, the
sampler/quote plumbing in telegram_bot.py, the channel_spread_detail
config knob and tiingo_api_key resolver, and Module 14's Tiingo tests
(221 -> 210). CLAUDE.md documents the verdict: do not re-add a spread
display from Tiingo's free FX feed.

Claude-Session: https://claude.ai/code/session_01BoZKi67Ct6QTeiFJmybXSw
This commit is contained in:
Harwinder Sekhon
2026-07-24 10:56:55 -04:00
parent 3a95a37a0c
commit 7a810c4a4c
6 changed files with 21 additions and 571 deletions
+8 -144
View File
@@ -67,12 +67,9 @@ from config import (
)
from app_config import (
get_app_config, provider_api_key, telegram_token, telegram_chat_id,
telegram_channel_id, tiingo_api_key,
telegram_channel_id,
)
from data_provider import ForexDataProvider, make_data_provider
from tiingo_provider import (
Quote, TiingoQuoteProvider, WIDE_SPREAD_MULTIPLE, log_quote,
)
from pair_signals import PAIR_PRODUCERS
from signal_aggregator import SignalAggregator, SignalReport
@@ -92,9 +89,8 @@ _console_handler = logging.StreamHandler()
_console_handler.setFormatter(_log_formatter)
# 3-hour rotation × 240 backups = 30 days retention. Handlers sit on the
# ROOT logger (force=True guarantees ownership even if something logged
# first), so every module logger — data_provider, tiingo_provider,
# news_engine, signal_aggregator, websockets, telegram.ext — lands in
# the same file.
# first), so every module logger — data_provider, news_engine,
# signal_aggregator, websockets, telegram.ext — lands in the same file.
_file_handler = logging.handlers.TimedRotatingFileHandler(
os.path.join(_LOG_DIR, "telegram.log"),
when="h", interval=3, backupCount=240, encoding="utf-8",
@@ -213,32 +209,6 @@ def _get_shared_dp() -> ForexDataProvider:
return _shared_dp
# ── Tiingo bid/ask spread monitor (optional; no key → feature off) ───
# Warn-only BY DESIGN: there is no historical bid/ask to replay, so a
# suppressing gate could never be validated (live == backtest rule).
# Signals never consume bid/ask — this is execution-cost visibility.
_TIINGO_KEY = tiingo_api_key()
_tiingo: Optional[TiingoQuoteProvider] = (
TiingoQuoteProvider(_TIINGO_KEY) if _TIINGO_KEY else None
)
_last_quotes: Dict[str, Quote] = {}
_QUOTE_MAX_AGE = 600 # cached quote stays displayable this long
_SPREAD_SAMPLE_SECONDS = 300 # sampler cadence (→ logs/spread_log.csv)
# config.json telegram.channel_spread_detail: True while the channel is
# internal/testing → it gets raw bid/ask like the owner DM. MUST be
# flipped back to false before the channel goes public (Tiingo free
# tier is internal-use-only).
_CHANNEL_SPREAD_DETAIL = _APP.channel_spread_detail
def _spread_detail_for(chat_id: int) -> bool:
"""Owner DMs always get raw quote numbers; the channel only while
config.json marks it internal."""
return chat_id != CHANNEL_ID or _CHANNEL_SPREAD_DETAIL
# ── Signal formatting ─────────────────────────────────────────────────
_SIGNAL_EMOJI = {
@@ -294,37 +264,6 @@ def _live_price_line(symbol: str) -> Optional[str]:
return f"📡 Live: *{_fmt_price(price)}* ({age:.0f}s tick)"
def _spread_lines(symbol: str, include_quote: bool = True) -> List[str]:
"""Live bid/ask + wide-spread warning for signal messages.
Empty when Tiingo is off or the cached quote is stale.
include_quote=False is the subscriber-channel variant: Tiingo's
free tier is licensed internal-use-only (no redistributing the
data), so raw bid/ask/spread numbers stay in the owner DM — the
channel gets only a number-free wide-spread caution."""
q = _last_quotes.get(symbol)
if q is None or q.age_seconds() > _QUOTE_MAX_AGE:
return []
assumed = PAIR_INFO.get(symbol, {}).get("default_spread", 0.0)
wide = assumed > 0 and q.spread >= WIDE_SPREAD_MULTIPLE * assumed
if not include_quote:
return [
"⚠️ Spread unusually wide right now — execution cost is "
"elevated, consider waiting for it to normalize"
] if wide else []
lines = [
f"💱 Bid/Ask: {_fmt_price(q.bid)} / {_fmt_price(q.ask)}"
f" (spread ${q.spread:.2f})"
]
if wide:
lines.append(
f"⚠️ Wide spread: ${q.spread:.2f} ≈ {q.spread / assumed:.1f}× the "
f"${assumed:.2f} cost the strategy was validated at — "
f"execution is expensive right now"
)
return lines
_DISCLAIMER = (
"⚠️ _Not financial advice. For educational and research purposes only. "
"Trading forex/CFDs carries a high risk of loss — do your own analysis "
@@ -332,7 +271,7 @@ _DISCLAIMER = (
)
def format_signal_message(report: SignalReport, spread_detail: bool = True) -> str:
def format_signal_message(report: SignalReport) -> str:
sig = report.final_signal
emoji = _SIGNAL_EMOJI.get(sig, "⬜")
qe = _QUALITY_EMOJI.get(report.signal_quality, "")
@@ -353,7 +292,6 @@ def format_signal_message(report: SignalReport, spread_detail: bool = True) -> s
live = _live_price_line(report.symbol)
if live:
lines.append(live)
lines += _spread_lines(report.symbol, include_quote=spread_detail)
lines += [
f"📊 Regime: {regime_str} | Vol: {vol_str}",
"",
@@ -551,24 +489,6 @@ async def run_signal(
)
async def _refresh_quotes(symbols: List[str]) -> None:
"""Fetch fresh Tiingo quotes (blocking HTTP) off the event loop —
called just before alerts go out so the spread block reflects the
moment of entry, and by the 5-min sampler."""
if _tiingo is None:
return
loop = asyncio.get_running_loop()
for symbol in set(symbols):
try:
quote = await loop.run_in_executor(
_executor, _tiingo.get_quote, symbol)
except Exception as exc:
logger.warning(f"Tiingo quote refresh failed for {symbol}: {exc}")
continue
if quote is not None:
_last_quotes[symbol] = quote
# ── Alert gating (quality threshold + dedup) ─────────────────────────
_QUALITY_RANK = {"A+": 4, "A": 3, "B": 2, "C": 1}
@@ -697,13 +617,10 @@ async def _scan_timeframe(
and _passes_quality(r.signal_quality)
and _should_alert(f"{r.symbol}_{tf.value}", r)
]
if actionable:
await _refresh_quotes([r.symbol for r in actionable])
sent_ok: Set[int] = set()
for chat_id in _signal_targets():
for i, report in enumerate(actionable):
detail = format_signal_message(
report, spread_detail=_spread_detail_for(chat_id))
detail = format_signal_message(report)
try:
await bot.send_message(chat_id, detail, parse_mode=ParseMode.MARKDOWN)
sent_ok.add(i)
@@ -801,11 +718,9 @@ async def _handle_breach(bot, ws_symbol: str, pair_symbol: str, price: float, le
f"⚡ *LEVEL BREAK* — {_escape_md(display)} crossed "
f"*{_escape_md(label)}* @ `{_fmt_price(price)}`\n\n"
)
await _refresh_quotes([pair_symbol])
sent = False
for chat_id in _signal_targets():
detail = header + format_signal_message(
report, spread_detail=_spread_detail_for(chat_id))
detail = header + format_signal_message(report)
try:
await bot.send_message(chat_id, detail, parse_mode=ParseMode.MARKDOWN)
sent = True
@@ -927,37 +842,6 @@ async def _candle_loop(bot, tf: Timeframe, interval_minutes: int) -> None:
logger.info(f"{label} auto-scan loop stopped")
def _weekend_closed(now: datetime) -> bool:
"""Fri 22:00 → Sun 22:00 UTC — the window the platform treats as
market-closed (same as the weekend filter)."""
wd, hr = now.weekday(), now.hour
return (wd == 4 and hr >= 22) or wd == 5 or (wd == 6 and hr < 22)
async def _spread_sampler_loop() -> None:
"""Every 5 min: fetch bid/ask for each scanned pair, cache it for
the alert formatter, and append to logs/spread_log.csv — the
dataset that validates (or refutes) the assumed $0.30 round-trip
cost every backtest charges."""
try:
while True:
try:
if not _weekend_closed(datetime.now(timezone.utc)):
await _refresh_quotes(SCAN_PAIRS)
for symbol in SCAN_PAIRS:
q = _last_quotes.get(symbol)
# Age guard: never log a stale row after a failed fetch.
if q is not None and q.age_seconds() < _SPREAD_SAMPLE_SECONDS:
log_quote(q)
except Exception:
logger.exception("Spread sampler: cycle failed")
await asyncio.sleep(_SPREAD_SAMPLE_SECONDS)
except asyncio.CancelledError:
pass
finally:
logger.info("Spread sampler stopped")
_TF_INTERVAL_MINUTES = {
Timeframe.M5: 5, Timeframe.M15: 15, Timeframe.M30: 30,
Timeframe.H1: 60, Timeframe.H4: 240, Timeframe.D1: 1440,
@@ -968,11 +852,10 @@ _AUTOSCAN_LOOPS = [
_ws_task: Optional[asyncio.Task] = None
_spread_task: Optional[asyncio.Task] = None
def _start_scheduler(bot) -> None:
global _ws_task, _spread_task
global _ws_task
alive = [t for t in _autoscan_tasks if not t.done()]
if not alive:
_autoscan_tasks.clear()
@@ -987,23 +870,15 @@ def _start_scheduler(bot) -> None:
_ws_task = asyncio.create_task(_ws_monitor(bot))
logger.info("WebSocket level-breach monitor started")
if _tiingo is not None and (_spread_task is None or _spread_task.done()):
_spread_task = asyncio.create_task(_spread_sampler_loop())
logger.info("Tiingo spread sampler started (every %ss)",
_SPREAD_SAMPLE_SECONDS)
def _stop_scheduler() -> None:
global _ws_task, _spread_task
global _ws_task
for t in _autoscan_tasks:
t.cancel()
_autoscan_tasks.clear()
if _ws_task and not _ws_task.done():
_ws_task.cancel()
_ws_task = None
if _spread_task and not _spread_task.done():
_spread_task.cancel()
_spread_task = None
for t in list(_breach_tasks):
t.cancel()
_breach_tasks.clear()
@@ -1101,17 +976,6 @@ def _status_text() -> str:
else:
lines.append("❌ WebSocket: *disconnected*")
if _tiingo is not None:
for pair in SCAN_PAIRS:
q = _last_quotes.get(pair)
display = PAIR_INFO.get(pair, {}).get("display", pair)
if q is None:
lines.append(f"💱 Spread ({display}): waiting for first quote")
else:
lines.append(
f"💱 Spread ({display}): `${q.spread:.2f}` "
f"({q.age_seconds():.0f}s ago)")
if _last_prices:
lines.append("\n*Live Prices (WebSocket):*")
for ws_sym, pair in _WS_TO_PAIR.items():