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. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HVRmy6KspMs96o5PTPYFAB
This commit is contained in:
+204
-13
@@ -43,6 +43,7 @@ import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime, timezone
|
||||
@@ -66,9 +67,12 @@ from config import (
|
||||
)
|
||||
from app_config import (
|
||||
get_app_config, provider_api_key, telegram_token, telegram_chat_id,
|
||||
telegram_channel_id,
|
||||
telegram_channel_id, tiingo_api_key,
|
||||
)
|
||||
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
|
||||
|
||||
@@ -81,17 +85,24 @@ if hasattr(sys.stdout, "reconfigure"):
|
||||
_LOG_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "logs")
|
||||
os.makedirs(_LOG_DIR, exist_ok=True)
|
||||
_log_formatter = logging.Formatter(
|
||||
"%(asctime)s [%(levelname)s] %(name)s: %(message)s", datefmt="%H:%M:%S"
|
||||
"%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
_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.
|
||||
_file_handler = logging.handlers.TimedRotatingFileHandler(
|
||||
os.path.join(_LOG_DIR, "telegram.log"),
|
||||
when="midnight", backupCount=30, encoding="utf-8",
|
||||
when="h", interval=3, backupCount=240, encoding="utf-8",
|
||||
)
|
||||
_file_handler.setFormatter(_log_formatter)
|
||||
logging.basicConfig(level=getattr(logging, _APP.log_level, logging.INFO),
|
||||
handlers=[_console_handler, _file_handler])
|
||||
handlers=[_console_handler, _file_handler],
|
||||
force=True)
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||
logger = logging.getLogger("telegram_bot")
|
||||
@@ -202,6 +213,32 @@ 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 = {
|
||||
@@ -257,6 +294,37 @@ 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 "
|
||||
@@ -264,7 +332,7 @@ _DISCLAIMER = (
|
||||
)
|
||||
|
||||
|
||||
def format_signal_message(report: SignalReport) -> str:
|
||||
def format_signal_message(report: SignalReport, spread_detail: bool = True) -> str:
|
||||
sig = report.final_signal
|
||||
emoji = _SIGNAL_EMOJI.get(sig, "⬜")
|
||||
qe = _QUALITY_EMOJI.get(report.signal_quality, "")
|
||||
@@ -285,6 +353,7 @@ def format_signal_message(report: SignalReport) -> str:
|
||||
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}",
|
||||
"",
|
||||
@@ -477,6 +546,24 @@ 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}
|
||||
@@ -589,10 +676,13 @@ async def _scan_timeframe(
|
||||
if _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)
|
||||
detail = format_signal_message(
|
||||
report, spread_detail=_spread_detail_for(chat_id))
|
||||
try:
|
||||
await bot.send_message(chat_id, detail, parse_mode=ParseMode.MARKDOWN)
|
||||
sent_ok.add(i)
|
||||
@@ -690,9 +780,11 @@ 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"
|
||||
)
|
||||
detail = header + format_signal_message(report)
|
||||
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))
|
||||
try:
|
||||
await bot.send_message(chat_id, detail, parse_mode=ParseMode.MARKDOWN)
|
||||
sent = True
|
||||
@@ -702,6 +794,15 @@ async def _handle_breach(bot, ws_symbol: str, pair_symbol: str, price: float, le
|
||||
_commit_alert(f"{pair_symbol}_{Timeframe.M15.value}", report)
|
||||
|
||||
|
||||
def _breach_task_done(task: asyncio.Task) -> None:
|
||||
"""Fire-and-forget breach tasks must still surface their failures —
|
||||
without this, an exception sits unretrieved until GC and never
|
||||
reaches the log file."""
|
||||
_breach_tasks.discard(task)
|
||||
if not task.cancelled() and task.exception() is not None:
|
||||
logger.error("Breach alert task failed", exc_info=task.exception())
|
||||
|
||||
|
||||
async def _ws_heartbeat(ws) -> None:
|
||||
"""Twelve Data drops idle WS connections; heartbeat every 10s keeps it
|
||||
alive through quiet periods (weekends, low-liquidity hours)."""
|
||||
@@ -764,7 +865,7 @@ async def _ws_monitor(bot) -> None:
|
||||
_handle_breach(bot, ws_symbol, pair_symbol, price, breached)
|
||||
)
|
||||
_breach_tasks.add(task)
|
||||
task.add_done_callback(_breach_tasks.discard)
|
||||
task.add_done_callback(_breach_task_done)
|
||||
|
||||
_cleanup_cooldowns()
|
||||
|
||||
@@ -791,15 +892,51 @@ async def _candle_loop(bot, tf: Timeframe, interval_minutes: int) -> None:
|
||||
if not _feed_active():
|
||||
break
|
||||
logger.info(f"{label} auto-scan: firing")
|
||||
await _scan_timeframe(
|
||||
bot, tf, AUTOSCAN_CONFIRMATIONS[tf]
|
||||
)
|
||||
try:
|
||||
await _scan_timeframe(
|
||||
bot, tf, AUTOSCAN_CONFIRMATIONS[tf]
|
||||
)
|
||||
except Exception:
|
||||
# One failed cycle must not kill the loop for good —
|
||||
# log it and wait for the next candle boundary.
|
||||
logger.exception(f"{label} auto-scan: cycle failed")
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
finally:
|
||||
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,
|
||||
@@ -810,10 +947,11 @@ _AUTOSCAN_LOOPS = [
|
||||
|
||||
|
||||
_ws_task: Optional[asyncio.Task] = None
|
||||
_spread_task: Optional[asyncio.Task] = None
|
||||
|
||||
|
||||
def _start_scheduler(bot) -> None:
|
||||
global _ws_task
|
||||
global _ws_task, _spread_task
|
||||
alive = [t for t in _autoscan_tasks if not t.done()]
|
||||
if not alive:
|
||||
_autoscan_tasks.clear()
|
||||
@@ -828,15 +966,23 @@ 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
|
||||
global _ws_task, _spread_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()
|
||||
@@ -934,6 +1080,17 @@ 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():
|
||||
@@ -1266,6 +1423,38 @@ async def _post_shutdown(app: Application) -> None:
|
||||
|
||||
# ── Entry point ───────────────────────────────────────────────────────
|
||||
|
||||
async def _on_error(update: object, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""python-telegram-bot routes every unhandled handler exception here;
|
||||
without a registered error handler PTB only half-logs them."""
|
||||
logger.error(
|
||||
"Unhandled error in Telegram handler (update=%s)",
|
||||
getattr(update, "update_id", update),
|
||||
exc_info=context.error,
|
||||
)
|
||||
|
||||
|
||||
def _install_excepthooks() -> None:
|
||||
"""Route uncaught exceptions from the main thread and worker threads
|
||||
into the rotating log file instead of bare stderr, so a crash right
|
||||
before exit is still recorded."""
|
||||
def hook(exc_type, exc, tb):
|
||||
if issubclass(exc_type, KeyboardInterrupt):
|
||||
sys.__excepthook__(exc_type, exc, tb)
|
||||
return
|
||||
logger.critical("Uncaught exception", exc_info=(exc_type, exc, tb))
|
||||
|
||||
def thread_hook(args):
|
||||
if issubclass(args.exc_type, SystemExit):
|
||||
return
|
||||
name = args.thread.name if args.thread else "?"
|
||||
logger.critical(
|
||||
f"Uncaught exception in thread {name}",
|
||||
exc_info=(args.exc_type, args.exc_value, args.exc_traceback))
|
||||
|
||||
sys.excepthook = hook
|
||||
threading.excepthook = thread_hook
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if not TOKEN or not CHAT_ID:
|
||||
raise SystemExit(
|
||||
@@ -1273,6 +1462,7 @@ def main() -> None:
|
||||
"and telegram.chat_id in config.json, or the TELEGRAM_TOKEN and "
|
||||
"TELEGRAM_CHAT_ID environment variables.")
|
||||
|
||||
_install_excepthooks()
|
||||
logger.info("Starting ForexBot2 Telegram bot…")
|
||||
|
||||
app = (
|
||||
@@ -1292,6 +1482,7 @@ def main() -> None:
|
||||
app.add_handler(CommandHandler("clearcache", cmd_clearcache))
|
||||
app.add_handler(CallbackQueryHandler(cb_scan, pattern="^scan_"))
|
||||
app.add_handler(CallbackQueryHandler(cb_menu, pattern="^menu_"))
|
||||
app.add_error_handler(_on_error)
|
||||
|
||||
logger.info("Bot running — press Ctrl+C to stop")
|
||||
app.run_polling(allowed_updates=Update.ALL_TYPES)
|
||||
|
||||
Reference in New Issue
Block a user