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:
+8
-144
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user