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:
Harwinder Sekhon
2026-07-22 17:18:14 -04:00
parent 0823c05868
commit 91b4a6bb5e
6 changed files with 733 additions and 27 deletions
+11 -7
View File
@@ -13,9 +13,10 @@ This file is the authoritative developer/agent reference. The human-facing docs
| Path | Lines | Purpose | | Path | Lines | Purpose |
|------|-------|---------| |------|-------|---------|
| `config.py` | ~374 | All config dataclasses, `PAIR_INFO` registry, per-pair strategy configs. **`PlatformConfig` defaults ARE the tuned XAU config.** | | `config.py` | ~374 | All config dataclasses, `PAIR_INFO` registry, per-pair strategy configs. **`PlatformConfig` defaults ARE the tuned XAU config.** |
| `app_config.py` | ~244 | config.json loader/validation, env-precedence resolvers, `apply_pair_overrides` deployment overlay | | `app_config.py` | ~290 | config.json loader/validation, env-precedence resolvers, repo-root `.env` loader (setdefault at import), `apply_pair_overrides` deployment overlay |
| `config.json.example` | 24 | committed schema template; real gitignored config.json holds secrets + deployment settings | | `config.json.example` | 27 | committed schema template; real gitignored config.json holds secrets + deployment settings |
| `data_provider.py` | ~347 | Twelve Data SDK wrapper: fetch, rate-limit (55 credits/min), in-memory cache, validation/cleaning, freshness checks | | `data_provider.py` | ~347 | Twelve Data SDK wrapper: fetch, rate-limit (55 credits/min), in-memory cache, validation/cleaning, freshness checks |
| `tiingo_provider.py` | ~160 | Tiingo `/tiingo/fx/top` bid/ask quotes (supplementary, NOT an OHLCV source): spread visibility on alerts (warn-only) + 5-min sampler → `logs/spread_log.csv`. No key → feature off |
| `engine.py` | ~1173 | `TechnicalEngine`: computes all indicators on one DataFrame → `TechnicalAnalysis` (composite score, regime, key levels) | | `engine.py` | ~1173 | `TechnicalEngine`: computes all indicators on one DataFrame → `TechnicalAnalysis` (composite score, regime, key levels) |
| `chart_patterns.py` | ~280 | Chart-pattern detectors for the XAU_CP archetype (pure functions, ATR-unit geometry, first-close-beyond triggers) | | `chart_patterns.py` | ~280 | Chart-pattern detectors for the XAU_CP archetype (pure functions, ATR-unit geometry, first-close-beyond triggers) |
| `signal_aggregator.py` | ~1447 | `SignalAggregator`: MTF analysis, news integration, gates/penalties, quality grade, trade-setup generation → `SignalReport` | | `signal_aggregator.py` | ~1447 | `SignalAggregator`: MTF analysis, news integration, gates/penalties, quality grade, trade-setup generation → `SignalReport` |
@@ -24,13 +25,13 @@ This file is the authoritative developer/agent reference. The human-facing docs
| `pair_signals/__init__.py` | 52 | `PAIR_PRODUCERS` registry + `produce_signal()` | | `pair_signals/__init__.py` | 52 | `PAIR_PRODUCERS` registry + `produce_signal()` |
| `pair_signals/xauusd.py` | 71 | XAU config factory + per-timeframe overrides (docstring = validation record) | | `pair_signals/xauusd.py` | 71 | XAU config factory + per-timeframe overrides (docstring = validation record) |
| `main.py` | ~348 | CLI: `python main.py --pair XAUUSD --timeframe H1 --detail/--json` | | `main.py` | ~348 | CLI: `python main.py --pair XAUUSD --timeframe H1 --detail/--json` |
| `test_all.py` | ~2800 | QA suite: **200 tests in 13 modules**, no live API needed. Must stay green. | | `test_all.py` | ~3100 | QA suite: **215 tests in 14 modules**, no live API needed. Must stay green. |
| `backtest/gpu_backtest.py` | ~2231 | Bar-replay backtester (CuPy→NumPy fallback) using the real `SignalAggregator` | | `backtest/gpu_backtest.py` | ~2231 | Bar-replay backtester (CuPy→NumPy fallback) using the real `SignalAggregator` |
| `backtest/diagnose_filters.py` | 609 | Filter-diagnosis tooling | | `backtest/diagnose_filters.py` | 609 | Filter-diagnosis tooling |
| `backtest/research/` | — | Calendar research infra (`ff_calendar_scraper.py`, `calendar_store.py`) + result CSVs | | `backtest/research/` | — | Calendar research infra (`ff_calendar_scraper.py`, `calendar_store.py`) + result CSVs |
| `backtest/*.csv` | — | Verification trade books from past tuning campaigns (`xau_*.csv`, …). **Gitignored, local-only** — used for manual before/after comparison, not by tests | | `backtest/*.csv` | — | Verification trade books from past tuning campaigns (`xau_*.csv`, …). **Gitignored, local-only** — used for manual before/after comparison, not by tests |
| `backtest/cache/` | — | Parquet OHLCV cache (raw bars) | | `backtest/cache/` | — | Parquet OHLCV cache (raw bars) |
| `logs/telegram.log` | — | Bot log (midnight rotation, 30 backups) | | `logs/telegram.log` | — | Bot log (3-hour rotation × 240 backups = 30-day retention); `logs/spread_log.csv` = Tiingo spread samples |
--- ---
@@ -272,8 +273,10 @@ Run: `python telegram_bot.py` (credentials resolve env `TELEGRAM_TOKEN`/`TELEGRA
- **Alert gating**: quality threshold (`_alert_min_quality`, default "C", menu cycles C→B→A) + fingerprint dedup per (symbol, TF) on (signal, quality, direction, entry bucket) with 1-hour re-arm — a persisting identical signal doesn't re-send every candle, but a setup that moved ≥ half its risk distance (new pullback/swing anchor) re-alerts. The fingerprint is committed only **after** a message reaches ≥1 target (`_commit_alert`), so a failed Telegram send doesn't consume the alert. - **Alert gating**: quality threshold (`_alert_min_quality`, default "C", menu cycles C→B→A) + fingerprint dedup per (symbol, TF) on (signal, quality, direction, entry bucket) with 1-hour re-arm — a persisting identical signal doesn't re-send every candle, but a setup that moved ≥ half its risk distance (new pullback/swing anchor) re-alerts. The fingerprint is committed only **after** a message reaches ≥1 target (`_commit_alert`), so a failed Telegram send doesn't consume the alert.
- **WebSocket monitor**: subscribes every `scan_pairs` symbol to TD quotes WS; 10 s heartbeat; exponential reconnect (≤60 s). On tick crossing any cached key level (`swing_high/low_1..3`, `ema_trend`, `bb_upper/lower`, `kc_upper/lower`; cache TTL 1 h) → immediate M15 rescan → "LEVEL BREAK" alert if actionable+changed (same gates as auto-scan), 5-min per-pair cooldown. - **WebSocket monitor**: subscribes every `scan_pairs` symbol to TD quotes WS; 10 s heartbeat; exponential reconnect (≤60 s). On tick crossing any cached key level (`swing_high/low_1..3`, `ema_trend`, `bb_upper/lower`, `kc_upper/lower`; cache TTL 1 h) → immediate M15 rescan → "LEVEL BREAK" alert if actionable+changed (same gates as auto-scan), 5-min per-pair cooldown.
- **Commands**: `/start` (menu), `/scan` (buttons: per pair — M5/M15/H1/H4 + Scan All TFs), `/autoscan` (toggle DM copy), `/status` (loops, WS, live prices, level cache, cooldowns), `/cache` (age per pair/TF), `/clearcache`, `/help`. - **Commands**: `/start` (menu), `/scan` (buttons: per pair — M5/M15/H1/H4 + Scan All TFs), `/autoscan` (toggle DM copy), `/status` (loops, WS, live prices, level cache, cooldowns), `/cache` (age per pair/TF), `/clearcache`, `/help`.
- **Tiingo spread monitor** (optional; on only when a Tiingo key resolves — see Environment variables): a 5-min sampler task fetches top-of-book bid/ask per scanned pair (`tiingo_provider.py`), logs to `logs/spread_log.csv` (skipping the weekend-closed window), and caches the latest quote; alert sends refresh the quote first, and signal messages then show live bid/ask + a warning when spread ≥ 2× the pair's assumed round-trip cost (`WIDE_SPREAD_MULTIPLE`). **Warn-only BY DESIGN** — no historical bid/ask exists, so a suppressing gate could never be validated (live == backtest rule); signals never consume bid/ask. `/status` shows the live spread per pair. **License (free tier is internal-use-only):** raw bid/ask/spread numbers go to owner DMs always; the channel gets them ONLY while config.json `telegram.channel_spread_detail=true` (the channel is internal/testing as of 2026-07-22 — **flip to false before the channel goes public**), otherwise a number-free "spread unusually wide" caution via `format_signal_message(report, spread_detail=False)` — routing in `_spread_detail_for`, test-enforced, committed default false. **Rate limits (free tier 50 req/h, 1000/day, checked 2026-07-22):** sampler 12/h + alert-batch refreshes ≤17/h + breach refreshes ≤12/h = worst case ≈41/h with ONE pair; adding pairs multiplies this — recheck before growing `scan_pairs`.
- Signal messages include the full indicator breakdown, warnings, trade setup, **trailing-stop management plan** (arm price + trail distance, from Signal/pb params by `strategy`), and a risk disclaimer on every outbound message. - Signal messages include the full indicator breakdown, warnings, trade setup, **trailing-stop management plan** (arm price + trail distance, from Signal/pb params by `strategy`), and a risk disclaimer on every outbound message.
- Blocking scan work runs in `ThreadPoolExecutor(max_workers=4)`; one shared `ForexDataProvider` across all scans (cache reuse). Logs to `logs/telegram.log`. - Blocking scan work runs in `ThreadPoolExecutor(max_workers=4)`; one shared `ForexDataProvider` across all scans (cache reuse).
- **Logging (hardened 2026-07-22)**: root-logger handlers (`force=True`) → console + `logs/telegram.log` (3-hour rotation, 240 backups = 30 days), full-date timestamps; every module logger (data_provider, tiingo_provider, news_engine, websockets, telegram.ext) lands in the same file. Failure surfaces all covered: candle loops and the spread sampler catch-and-continue per cycle (one bad scan can't kill a loop), breach tasks log exceptions via done-callback, PTB `add_error_handler` registered, and `sys.excepthook`/`threading.excepthook` (installed in `main()`) route uncaught crashes to the log. WS monitor keeps its exponential-backoff reconnect + `logger.exception`.
--- ---
@@ -318,13 +321,13 @@ Data: paginated TD fetches (5000-bar chunks) cached as parquet in `backtest/cach
## test_all.py — QA suite ## test_all.py — QA suite
`python test_all.py` — **200 tests, 13 modules** (config, data_provider, engine, news_engine, signal_aggregator, main, telegram_bot, integration, pair_signals registry + engine-bug regressions, 2026-07-16 audit-fix regressions, chart patterns, app_config). No network needed (mocked). Uses a custom `@test("name")` decorator that runs immediately (NOT pytest — don't grep for `def test_`). Exit code non-zero on failure. Includes: the xauusd-factory ≡ inline-config equivalence test, engine bug-fix regression tests (Module 9b), a live-vs-GPU engine parity sweep incl. the EMA-ribbon path (Module 11), and config.json loading/precedence/validation coverage (Module 13). **Any signal-logic change must keep this green and should add tests.** `python test_all.py` — **215 tests, 14 modules** (config, data_provider, engine, news_engine, signal_aggregator, main, telegram_bot, integration, pair_signals registry + engine-bug regressions, 2026-07-16 audit-fix regressions, chart patterns, app_config, tiingo_provider). No network needed (mocked). Uses a custom `@test("name")` decorator that runs immediately (NOT pytest — don't grep for `def test_`). Exit code non-zero on failure. Includes: the xauusd-factory ≡ inline-config equivalence test, engine bug-fix regression tests (Module 9b), a live-vs-GPU engine parity sweep incl. the EMA-ribbon path (Module 11), config.json loading/precedence/validation coverage (Module 13), and Tiingo quote/spread-monitor + `.env` loader coverage (Module 14). **Any signal-logic change must keep this green and should add tests.**
--- ---
## Environment variables ## Environment variables
Precedence for every credential/deployment setting: **env var → config.json → none**. Hardcoded fallbacks (previously in `config.py` / `telegram_bot.py`) were **REMOVED 2026-07-21**; the bot now refuses to boot (`telegram_bot.main`) and data fetches fail with a named error (`data_provider`) if a secret resolves to neither source. See `app_config.py` and `config.json.example`. Precedence for every credential/deployment setting: **env var → config.json → none**. A repo-root `.env` file (gitignored) is loaded at `app_config` import via `os.environ.setdefault` — it behaves like real env vars except a real shell export always wins. Hardcoded fallbacks (previously in `config.py` / `telegram_bot.py`) were **REMOVED 2026-07-21**; the bot now refuses to boot (`telegram_bot.main`) and data fetches fail with a named error (`data_provider`) if a secret resolves to neither source. See `app_config.py` and `config.json.example`.
| Variable | Purpose | Default | | Variable | Purpose | Default |
|---|---|---| |---|---|---|
@@ -332,6 +335,7 @@ Precedence for every credential/deployment setting: **env var → config.json
| `TELEGRAM_TOKEN` | bot token | none — falls through to config.json `telegram.token`, then refuses to boot | | `TELEGRAM_TOKEN` | bot token | none — falls through to config.json `telegram.token`, then refuses to boot |
| `TELEGRAM_CHAT_ID` | owner chat id (boot auto-scan + owner lock) | none — falls through to config.json `telegram.chat_id`, then refuses to boot | | `TELEGRAM_CHAT_ID` | owner chat id (boot auto-scan + owner lock) | none — falls through to config.json `telegram.chat_id`, then refuses to boot |
| `TELEGRAM_CHANNEL_ID` | subscriber channel (`-100…`); `""` disables the feed | none — falls through to config.json `telegram.channel_id` | | `TELEGRAM_CHANNEL_ID` | subscriber channel (`-100…`); `""` disables the feed | none — falls through to config.json `telegram.channel_id` |
| `TIINGO_API_KEY` (or `TIINGO`) | Tiingo bid/ask quote key for the spread monitor — optional | none — falls through to config.json `data_providers.tiingo.api_key`; absent = spread monitor off (never fatal) |
| `FB2_EMA_LO` | EMA200 "near" penalty threshold in % | `0.5` | | `FB2_EMA_LO` | EMA200 "near" penalty threshold in % | `0.5` |
Dependencies (`requirements.txt`): numpy, pandas, requests, twelvedata, python-telegram-bot ≥20, websockets. Python 3.12. A `venv/` exists in-repo. Dependencies (`requirements.txt`): numpy, pandas, requests, twelvedata, python-telegram-bot ≥20, websockets. Python 3.12. A `venv/` exists in-repo.
+66 -4
View File
@@ -9,9 +9,12 @@ alert-quality boot default, log level. Signal logic, weights,
thresholds and gates stay in config.py / pair_signals. thresholds and gates stay in config.py / pair_signals.
Precedence for every setting: env var → config.json → code default. Precedence for every setting: env var → config.json → code default.
Secrets have NO code default — with neither env nor config.json the A repo-root .env file (gitignored) is loaded at import via setdefault,
bot refuses to start (telegram_bot.main) and data fetches fail with a so it behaves exactly like real environment variables except that a
named error (data_provider). real shell export always wins. Secrets have NO code default — with
neither env nor config.json the bot refuses to start
(telegram_bot.main) and data fetches fail with a named error
(data_provider).
Parity contract (test-enforced): with no config.json, or one holding Parity contract (test-enforced): with no config.json, or one holding
the config.json.example defaults, every produced PlatformConfig is the config.json.example defaults, every produced PlatformConfig is
@@ -34,6 +37,40 @@ logger = logging.getLogger(__name__)
CONFIG_PATH = os.path.join( CONFIG_PATH = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "config.json") os.path.dirname(os.path.abspath(__file__)), "config.json")
ENV_PATH = os.path.join(
os.path.dirname(os.path.abspath(__file__)), ".env")
def load_dotenv(path: str = ENV_PATH) -> None:
"""Minimal .env loader (no python-dotenv dependency).
KEY=VALUE lines land in os.environ via setdefault — a variable
already exported in the shell always wins, keeping the
env → config.json precedence intact (.env is just a convenient
place to put env vars). Comments, blank lines, ``export `` prefixes
and single/double quotes are handled; anything else is skipped.
"""
try:
with open(path, encoding="utf-8") as fh:
lines = fh.read().splitlines()
except OSError:
return
for line in lines:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
if line.startswith("export "):
line = line[len("export "):]
key, _, value = line.partition("=")
key, value = key.strip(), value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
value = value[1:-1]
if key:
os.environ.setdefault(key, value)
# Module import == process boot, before any resolver can run.
load_dotenv()
_VALID_QUALITIES = ("A+", "A", "B", "C") _VALID_QUALITIES = ("A+", "A", "B", "C")
_VALID_LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL") _VALID_LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")
@@ -42,7 +79,8 @@ _VALID_LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")
_SCANNABLE_TFS = ("M5", "M15", "H1", "H4") _SCANNABLE_TFS = ("M5", "M15", "H1", "H4")
_KNOWN_TOP = {"telegram", "data_providers", "scanning", "pairs", "log_level"} _KNOWN_TOP = {"telegram", "data_providers", "scanning", "pairs", "log_level"}
_KNOWN_TELEGRAM = {"token", "chat_id", "channel_id", "alert_min_quality"} _KNOWN_TELEGRAM = {"token", "chat_id", "channel_id", "alert_min_quality",
"channel_spread_detail"}
_KNOWN_SCANNING = {"pairs", "timeframes"} _KNOWN_SCANNING = {"pairs", "timeframes"}
_KNOWN_PAIR_KEYS = {"blocked_hours_utc"} _KNOWN_PAIR_KEYS = {"blocked_hours_utc"}
@@ -57,6 +95,11 @@ class AppConfig:
telegram_chat_id: str = "" telegram_chat_id: str = ""
telegram_channel_id: str = "" # "" = channel feed disabled telegram_channel_id: str = "" # "" = channel feed disabled
alert_min_quality: str = "C" alert_min_quality: str = "C"
# True = the channel is INTERNAL (testing) and may receive raw Tiingo
# bid/ask numbers. Default False: Tiingo's free tier is licensed
# internal-use-only, so a public/subscriber channel must only ever
# get the number-free wide-spread caution. Flip back before launch.
channel_spread_detail: bool = False
active_provider: str = "twelve_data" active_provider: str = "twelve_data"
provider_settings: Dict[str, Dict[str, Any]] = field(default_factory=dict) provider_settings: Dict[str, Dict[str, Any]] = field(default_factory=dict)
scan_pairs: List[str] = field(default_factory=lambda: [XAUUSD]) scan_pairs: List[str] = field(default_factory=lambda: [XAUUSD])
@@ -105,6 +148,11 @@ def load_app_config(path: str = CONFIG_PATH) -> AppConfig:
f'config.json: telegram.alert_min_quality "{quality}" ' f'config.json: telegram.alert_min_quality "{quality}" '
f"not in {_VALID_QUALITIES}") f"not in {_VALID_QUALITIES}")
app.alert_min_quality = quality app.alert_min_quality = quality
spread_detail = tg.get("channel_spread_detail", app.channel_spread_detail)
if not isinstance(spread_detail, bool):
raise AppConfigError(
"config.json: telegram.channel_spread_detail must be a boolean")
app.channel_spread_detail = spread_detail
dp = raw.get("data_providers") dp = raw.get("data_providers")
if dp is not None: if dp is not None:
@@ -227,6 +275,20 @@ def provider_api_key(app: Optional[AppConfig] = None) -> str:
return str(block.get("api_key", "")) return str(block.get("api_key", ""))
def tiingo_api_key(app: Optional[AppConfig] = None) -> str:
"""Key for the SUPPLEMENTARY Tiingo bid/ask quote provider (spread
monitor) — never a data_providers.active candidate; Twelve Data
stays the OHLCV source. Empty string = feature disabled.
Env TIINGO_API_KEY (or short TIINGO, e.g. from .env) →
config.json data_providers.tiingo.api_key → ""."""
app = app or get_app_config()
env = os.environ.get("TIINGO_API_KEY") or os.environ.get("TIINGO")
if env:
return env
block = app.provider_settings.get("tiingo", {})
return str(block.get("api_key", ""))
# ── PlatformConfig overlay ──────────────────────────────────────────── # ── PlatformConfig overlay ────────────────────────────────────────────
def apply_pair_overrides( def apply_pair_overrides(
+5 -1
View File
@@ -3,12 +3,16 @@
"token": "123456789:REPLACE_WITH_YOUR_BOT_TOKEN", "token": "123456789:REPLACE_WITH_YOUR_BOT_TOKEN",
"chat_id": "REPLACE_WITH_OWNER_CHAT_ID", "chat_id": "REPLACE_WITH_OWNER_CHAT_ID",
"channel_id": "-100REPLACE_WITH_CHANNEL_ID_OR_EMPTY_STRING", "channel_id": "-100REPLACE_WITH_CHANNEL_ID_OR_EMPTY_STRING",
"alert_min_quality": "C" "alert_min_quality": "C",
"channel_spread_detail": false
}, },
"data_providers": { "data_providers": {
"active": "twelve_data", "active": "twelve_data",
"twelve_data": { "twelve_data": {
"api_key": "REPLACE_WITH_YOUR_TWELVE_DATA_API_KEY" "api_key": "REPLACE_WITH_YOUR_TWELVE_DATA_API_KEY"
},
"tiingo": {
"api_key": ""
} }
}, },
"scanning": { "scanning": {
+201 -10
View File
@@ -43,6 +43,7 @@ import logging
import logging.handlers import logging.handlers
import os import os
import sys import sys
import threading
import time import time
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -66,9 +67,12 @@ from config import (
) )
from app_config import ( from app_config import (
get_app_config, provider_api_key, telegram_token, telegram_chat_id, 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 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 pair_signals import PAIR_PRODUCERS
from signal_aggregator import SignalAggregator, SignalReport 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") _LOG_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "logs")
os.makedirs(_LOG_DIR, exist_ok=True) os.makedirs(_LOG_DIR, exist_ok=True)
_log_formatter = logging.Formatter( _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 = logging.StreamHandler()
_console_handler.setFormatter(_log_formatter) _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( _file_handler = logging.handlers.TimedRotatingFileHandler(
os.path.join(_LOG_DIR, "telegram.log"), 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) _file_handler.setFormatter(_log_formatter)
logging.basicConfig(level=getattr(logging, _APP.log_level, logging.INFO), 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("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING) logging.getLogger("httpcore").setLevel(logging.WARNING)
logger = logging.getLogger("telegram_bot") logger = logging.getLogger("telegram_bot")
@@ -202,6 +213,32 @@ def _get_shared_dp() -> ForexDataProvider:
return _shared_dp 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 formatting ─────────────────────────────────────────────────
_SIGNAL_EMOJI = { _SIGNAL_EMOJI = {
@@ -257,6 +294,37 @@ def _live_price_line(symbol: str) -> Optional[str]:
return f"📡 Live: *{_fmt_price(price)}* ({age:.0f}s tick)" 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 = ( _DISCLAIMER = (
"⚠️ _Not financial advice. For educational and research purposes only. " "⚠️ _Not financial advice. For educational and research purposes only. "
"Trading forex/CFDs carries a high risk of loss — do your own analysis " "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 sig = report.final_signal
emoji = _SIGNAL_EMOJI.get(sig, "⬜") emoji = _SIGNAL_EMOJI.get(sig, "⬜")
qe = _QUALITY_EMOJI.get(report.signal_quality, "") qe = _QUALITY_EMOJI.get(report.signal_quality, "")
@@ -285,6 +353,7 @@ def format_signal_message(report: SignalReport) -> str:
live = _live_price_line(report.symbol) live = _live_price_line(report.symbol)
if live: if live:
lines.append(live) lines.append(live)
lines += _spread_lines(report.symbol, include_quote=spread_detail)
lines += [ lines += [
f"📊 Regime: {regime_str} | Vol: {vol_str}", 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) ───────────────────────── # ── Alert gating (quality threshold + dedup) ─────────────────────────
_QUALITY_RANK = {"A+": 4, "A": 3, "B": 2, "C": 1} _QUALITY_RANK = {"A+": 4, "A": 3, "B": 2, "C": 1}
@@ -589,10 +676,13 @@ async def _scan_timeframe(
if _passes_quality(r.signal_quality) if _passes_quality(r.signal_quality)
and _should_alert(f"{r.symbol}_{tf.value}", r) 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() sent_ok: Set[int] = set()
for chat_id in _signal_targets(): for chat_id in _signal_targets():
for i, report in enumerate(actionable): for i, report in enumerate(actionable):
detail = format_signal_message(report) detail = format_signal_message(
report, spread_detail=_spread_detail_for(chat_id))
try: try:
await bot.send_message(chat_id, detail, parse_mode=ParseMode.MARKDOWN) await bot.send_message(chat_id, detail, parse_mode=ParseMode.MARKDOWN)
sent_ok.add(i) 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"⚡ *LEVEL BREAK* — {_escape_md(display)} crossed "
f"*{_escape_md(label)}* @ `{_fmt_price(price)}`\n\n" f"*{_escape_md(label)}* @ `{_fmt_price(price)}`\n\n"
) )
detail = header + format_signal_message(report) await _refresh_quotes([pair_symbol])
sent = False sent = False
for chat_id in _signal_targets(): for chat_id in _signal_targets():
detail = header + format_signal_message(
report, spread_detail=_spread_detail_for(chat_id))
try: try:
await bot.send_message(chat_id, detail, parse_mode=ParseMode.MARKDOWN) await bot.send_message(chat_id, detail, parse_mode=ParseMode.MARKDOWN)
sent = True 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) _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: async def _ws_heartbeat(ws) -> None:
"""Twelve Data drops idle WS connections; heartbeat every 10s keeps it """Twelve Data drops idle WS connections; heartbeat every 10s keeps it
alive through quiet periods (weekends, low-liquidity hours).""" 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) _handle_breach(bot, ws_symbol, pair_symbol, price, breached)
) )
_breach_tasks.add(task) _breach_tasks.add(task)
task.add_done_callback(_breach_tasks.discard) task.add_done_callback(_breach_task_done)
_cleanup_cooldowns() _cleanup_cooldowns()
@@ -791,15 +892,51 @@ async def _candle_loop(bot, tf: Timeframe, interval_minutes: int) -> None:
if not _feed_active(): if not _feed_active():
break break
logger.info(f"{label} auto-scan: firing") logger.info(f"{label} auto-scan: firing")
try:
await _scan_timeframe( await _scan_timeframe(
bot, tf, AUTOSCAN_CONFIRMATIONS[tf] 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: except asyncio.CancelledError:
pass pass
finally: finally:
logger.info(f"{label} auto-scan loop stopped") 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 = { _TF_INTERVAL_MINUTES = {
Timeframe.M5: 5, Timeframe.M15: 15, Timeframe.M30: 30, Timeframe.M5: 5, Timeframe.M15: 15, Timeframe.M30: 30,
Timeframe.H1: 60, Timeframe.H4: 240, Timeframe.D1: 1440, Timeframe.H1: 60, Timeframe.H4: 240, Timeframe.D1: 1440,
@@ -810,10 +947,11 @@ _AUTOSCAN_LOOPS = [
_ws_task: Optional[asyncio.Task] = None _ws_task: Optional[asyncio.Task] = None
_spread_task: Optional[asyncio.Task] = None
def _start_scheduler(bot) -> 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()] alive = [t for t in _autoscan_tasks if not t.done()]
if not alive: if not alive:
_autoscan_tasks.clear() _autoscan_tasks.clear()
@@ -828,15 +966,23 @@ def _start_scheduler(bot) -> None:
_ws_task = asyncio.create_task(_ws_monitor(bot)) _ws_task = asyncio.create_task(_ws_monitor(bot))
logger.info("WebSocket level-breach monitor started") 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: def _stop_scheduler() -> None:
global _ws_task global _ws_task, _spread_task
for t in _autoscan_tasks: for t in _autoscan_tasks:
t.cancel() t.cancel()
_autoscan_tasks.clear() _autoscan_tasks.clear()
if _ws_task and not _ws_task.done(): if _ws_task and not _ws_task.done():
_ws_task.cancel() _ws_task.cancel()
_ws_task = None _ws_task = None
if _spread_task and not _spread_task.done():
_spread_task.cancel()
_spread_task = None
for t in list(_breach_tasks): for t in list(_breach_tasks):
t.cancel() t.cancel()
_breach_tasks.clear() _breach_tasks.clear()
@@ -934,6 +1080,17 @@ def _status_text() -> str:
else: else:
lines.append("❌ WebSocket: *disconnected*") 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: if _last_prices:
lines.append("\n*Live Prices (WebSocket):*") lines.append("\n*Live Prices (WebSocket):*")
for ws_sym, pair in _WS_TO_PAIR.items(): for ws_sym, pair in _WS_TO_PAIR.items():
@@ -1266,6 +1423,38 @@ async def _post_shutdown(app: Application) -> None:
# ── Entry point ─────────────────────────────────────────────────────── # ── 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: def main() -> None:
if not TOKEN or not CHAT_ID: if not TOKEN or not CHAT_ID:
raise SystemExit( raise SystemExit(
@@ -1273,6 +1462,7 @@ def main() -> None:
"and telegram.chat_id in config.json, or the TELEGRAM_TOKEN and " "and telegram.chat_id in config.json, or the TELEGRAM_TOKEN and "
"TELEGRAM_CHAT_ID environment variables.") "TELEGRAM_CHAT_ID environment variables.")
_install_excepthooks()
logger.info("Starting ForexBot2 Telegram bot…") logger.info("Starting ForexBot2 Telegram bot…")
app = ( app = (
@@ -1292,6 +1482,7 @@ def main() -> None:
app.add_handler(CommandHandler("clearcache", cmd_clearcache)) app.add_handler(CommandHandler("clearcache", cmd_clearcache))
app.add_handler(CallbackQueryHandler(cb_scan, pattern="^scan_")) app.add_handler(CallbackQueryHandler(cb_scan, pattern="^scan_"))
app.add_handler(CallbackQueryHandler(cb_menu, pattern="^menu_")) app.add_handler(CallbackQueryHandler(cb_menu, pattern="^menu_"))
app.add_error_handler(_on_error)
logger.info("Bot running — press Ctrl+C to stop") logger.info("Bot running — press Ctrl+C to stop")
app.run_polling(allowed_updates=Update.ALL_TYPES) app.run_polling(allowed_updates=Update.ALL_TYPES)
+290 -2
View File
@@ -25,7 +25,7 @@ sys.path.insert(0, os.path.dirname(__file__))
import json import json
import app_config import app_config
for _k in ("TELEGRAM_TOKEN", "TELEGRAM_CHAT_ID", "TELEGRAM_CHANNEL_ID", for _k in ("TELEGRAM_TOKEN", "TELEGRAM_CHAT_ID", "TELEGRAM_CHANNEL_ID",
"TWELVE_DATA_API_KEY"): "TWELVE_DATA_API_KEY", "TIINGO_API_KEY", "TIINGO"):
os.environ.pop(_k, None) os.environ.pop(_k, None)
_TEST_APP = app_config.AppConfig( _TEST_APP = app_config.AppConfig(
provider_settings={"twelve_data": {"api_key": "test-key"}} provider_settings={"twelve_data": {"api_key": "test-key"}}
@@ -2607,7 +2607,7 @@ def _write_json(payload) -> str:
_FULL_CFG = { _FULL_CFG = {
"telegram": {"token": "t0k", "chat_id": "111", "channel_id": "-100222", "telegram": {"token": "t0k", "chat_id": "111", "channel_id": "-100222",
"alert_min_quality": "B"}, "alert_min_quality": "B", "channel_spread_detail": True},
"data_providers": {"active": "twelve_data", "data_providers": {"active": "twelve_data",
"twelve_data": {"api_key": "json-key"}}, "twelve_data": {"api_key": "json-key"}},
"scanning": {"pairs": ["XAU/USD"], "timeframes": ["M15", "H1"]}, "scanning": {"pairs": ["XAU/USD"], "timeframes": ["M15", "H1"]},
@@ -2623,6 +2623,7 @@ def _():
assert app.telegram_chat_id == "111" assert app.telegram_chat_id == "111"
assert app.telegram_channel_id == "-100222" assert app.telegram_channel_id == "-100222"
assert app.alert_min_quality == "B" assert app.alert_min_quality == "B"
assert app.channel_spread_detail is True
assert app.active_provider == "twelve_data" assert app.active_provider == "twelve_data"
assert app.provider_settings["twelve_data"]["api_key"] == "json-key" assert app.provider_settings["twelve_data"]["api_key"] == "json-key"
assert app.scan_pairs == [XAUUSD] assert app.scan_pairs == [XAUUSD]
@@ -2676,6 +2677,7 @@ def _():
{"data_providers": {"active": "polygon", {"data_providers": {"active": "polygon",
"twelve_data": {"api_key": "k"}}}, # no block "twelve_data": {"api_key": "k"}}}, # no block
{"telegram": {"alert_min_quality": "Z"}}, # bad quality {"telegram": {"alert_min_quality": "Z"}}, # bad quality
{"telegram": {"channel_spread_detail": "yes"}}, # not a bool
{"log_level": "CHATTY"}, # bad level {"log_level": "CHATTY"}, # bad level
] ]
for payload in bad_payloads: for payload in bad_payloads:
@@ -2857,6 +2859,292 @@ def _():
Timeframe.H1, Timeframe.H4] Timeframe.H1, Timeframe.H4]
# ══════════════════════════════════════════════════════════════════════
# MODULE 14: tiingo_provider.py (bid/ask spread monitor)
# ══════════════════════════════════════════════════════════════════════
print("\n═══ Module 14: tiingo_provider.py ═══")
from tiingo_provider import (
Quote, TiingoQuoteProvider, WIDE_SPREAD_MULTIPLE, log_quote,
tiingo_ticker,
)
from app_config import load_dotenv, tiingo_api_key
def _tiingo_response(payload):
resp = MagicMock()
resp.json.return_value = payload
resp.raise_for_status.return_value = None
return resp
_TIINGO_ROW = [{
"ticker": "xauusd",
"quoteTimestamp": "2026-07-22T12:00:00+00:00",
"bidPrice": 2400.10, "bidSize": 1000000.0,
"askPrice": 2400.40, "askSize": 1000000.0,
"midPrice": 2400.25,
}]
@test("tiingo: ticker mapping XAU/USD → xauusd")
def _():
assert tiingo_ticker("XAU/USD") == "xauusd"
assert tiingo_ticker("EUR/USD") == "eurusd"
@test("tiingo: quote parsed — spread = ask − bid, canonical symbol")
def _():
provider = TiingoQuoteProvider("k")
with patch("tiingo_provider.requests.get",
return_value=_tiingo_response(_TIINGO_ROW)) as mock_get:
q = provider.get_quote("XAU/USD")
assert q is not None
assert q.symbol == "XAU/USD"
assert abs(q.bid - 2400.10) < 1e-9 and abs(q.ask - 2400.40) < 1e-9
assert abs(q.spread - 0.30) < 1e-9
assert abs(q.mid - 2400.25) < 1e-9
assert q.quote_time is not None and q.quote_time.tzinfo is not None
assert q.age_seconds() < 5
params = mock_get.call_args.kwargs["params"]
assert params["tickers"] == "xauusd" and params["token"] == "k"
@test("tiingo: mid falls back to (bid+ask)/2 when midPrice missing")
def _():
row = [dict(_TIINGO_ROW[0], midPrice=None)]
provider = TiingoQuoteProvider("k")
with patch("tiingo_provider.requests.get",
return_value=_tiingo_response(row)):
q = provider.get_quote("XAU/USD")
assert q is not None and abs(q.mid - 2400.25) < 1e-9
@test("tiingo: TTL cache dedupes calls; ttl=0 refetches")
def _():
provider = TiingoQuoteProvider("k")
with patch("tiingo_provider.requests.get",
return_value=_tiingo_response(_TIINGO_ROW)) as mock_get:
provider.get_quote("XAU/USD")
provider.get_quote("XAU/USD")
assert mock_get.call_count == 1, "second call within TTL must hit cache"
provider = TiingoQuoteProvider("k", quote_ttl=0.0)
with patch("tiingo_provider.requests.get",
return_value=_tiingo_response(_TIINGO_ROW)) as mock_get:
provider.get_quote("XAU/USD")
provider.get_quote("XAU/USD")
assert mock_get.call_count == 2
@test("tiingo: every failure path returns None (never raises)")
def _():
provider = TiingoQuoteProvider("k", quote_ttl=0.0)
# network error
with patch("tiingo_provider.requests.get",
side_effect=Exception("boom")):
assert provider.get_quote("XAU/USD") is None
# HTTP error
resp = _tiingo_response(_TIINGO_ROW)
resp.raise_for_status.side_effect = Exception("401")
with patch("tiingo_provider.requests.get", return_value=resp):
assert provider.get_quote("XAU/USD") is None
# empty / non-list / null-price payloads
for payload in ([], {"detail": "unknown ticker"},
[dict(_TIINGO_ROW[0], bidPrice=None)],
[dict(_TIINGO_ROW[0], askPrice=None)]):
with patch("tiingo_provider.requests.get",
return_value=_tiingo_response(payload)):
assert provider.get_quote("XAU/USD") is None, \
f"payload {payload!r} must yield None"
@test("tiingo: log_quote writes header once then appends rows")
def _():
q = Quote(symbol=XAUUSD, bid=2400.10, ask=2400.40, mid=2400.25,
spread=0.30, quote_time=None, fetched_at=time.time())
with tempfile.TemporaryDirectory() as td:
path = os.path.join(td, "sub", "spread_log.csv")
log_quote(q, path)
log_quote(q, path)
with open(path, encoding="utf-8") as fh:
lines = fh.read().strip().splitlines()
assert lines[0] == "ts_utc,symbol,bid,ask,mid,spread"
assert len(lines) == 3, "header + one row per log_quote call"
cells = lines[1].split(",")
assert cells[1] == XAUUSD and float(cells[5]) == 0.30
@test("app_config: tiingo_api_key precedence env → env-short → json → ''")
def _():
app = AppConfig(provider_settings={
"twelve_data": {"api_key": "td"},
"tiingo": {"api_key": "json-tiingo"},
})
with patch.dict(os.environ, {}, clear=True):
assert tiingo_api_key(app) == "json-tiingo"
with patch.dict(os.environ, {"TIINGO": "short-env"}, clear=True):
assert tiingo_api_key(app) == "short-env"
with patch.dict(os.environ, {"TIINGO": "short-env",
"TIINGO_API_KEY": "long-env"}, clear=True):
assert tiingo_api_key(app) == "long-env"
with patch.dict(os.environ, {}, clear=True):
assert tiingo_api_key(AppConfig()) == "", "no key anywhere = disabled"
@test("app_config: load_dotenv parses lines, never overrides real env")
def _():
fh = tempfile.NamedTemporaryFile(
"w", suffix=".env", delete=False, encoding="utf-8")
fh.write("# comment\n\nPLAIN=abc\nexport EXPORTED=def\n"
'QUOTED="with spaces"\nSINGLE=\'sq\'\nEXISTING=from-dotenv\n'
"NOT A KV LINE\n")
fh.close()
with patch.dict(os.environ, {"EXISTING": "from-shell"}, clear=True):
load_dotenv(fh.name)
assert os.environ["PLAIN"] == "abc"
assert os.environ["EXPORTED"] == "def"
assert os.environ["QUOTED"] == "with spaces"
assert os.environ["SINGLE"] == "sq"
assert os.environ["EXISTING"] == "from-shell", \
"a real shell export must beat .env"
load_dotenv("/nonexistent/.env") # missing file is a silent no-op
@test("telegram_bot: _spread_lines fresh/wide/stale/absent")
def _():
import telegram_bot as tb
try:
# fresh, normal spread → bid/ask line only
tb._last_quotes[XAUUSD] = Quote(
symbol=XAUUSD, bid=2400.10, ask=2400.40, mid=2400.25,
spread=0.30, quote_time=None, fetched_at=time.time())
lines = tb._spread_lines(XAUUSD)
assert len(lines) == 1 and "Bid/Ask" in lines[0]
assert "spread $0.30" in lines[0]
# wide spread (≥ 2× the $0.30 assumption) → warning appended
tb._last_quotes[XAUUSD] = Quote(
symbol=XAUUSD, bid=2400.10, ask=2401.00, mid=2400.55,
spread=0.90, quote_time=None, fetched_at=time.time())
lines = tb._spread_lines(XAUUSD)
assert len(lines) == 2 and "Wide spread" in lines[1]
assert "3.0×" in lines[1]
# stale quote → nothing
tb._last_quotes[XAUUSD] = Quote(
symbol=XAUUSD, bid=2400.10, ask=2400.40, mid=2400.25,
spread=0.30, quote_time=None,
fetched_at=time.time() - tb._QUOTE_MAX_AGE - 1)
assert tb._spread_lines(XAUUSD) == []
# no quote at all → nothing
tb._last_quotes.pop(XAUUSD, None)
assert tb._spread_lines(XAUUSD) == []
finally:
tb._last_quotes.pop(XAUUSD, None)
@test("telegram_bot: channel variant never leaks Tiingo numbers")
def _():
# Tiingo free tier is internal-use-only: the subscriber channel may
# see a number-free caution, never bid/ask/spread values.
import telegram_bot as tb
try:
# normal spread → channel variant shows nothing at all
tb._last_quotes[XAUUSD] = Quote(
symbol=XAUUSD, bid=2400.10, ask=2400.40, mid=2400.25,
spread=0.30, quote_time=None, fetched_at=time.time())
assert tb._spread_lines(XAUUSD, include_quote=False) == []
# wide spread → one caution line with no prices in it
tb._last_quotes[XAUUSD] = Quote(
symbol=XAUUSD, bid=2400.10, ask=2401.00, mid=2400.55,
spread=0.90, quote_time=None, fetched_at=time.time())
lines = tb._spread_lines(XAUUSD, include_quote=False)
assert len(lines) == 1 and "unusually wide" in lines[0]
assert "2400" not in lines[0] and "0.90" not in lines[0] \
and "$" not in lines[0], "channel line must carry no data values"
# format_signal_message plumbs the flag through
config = PlatformConfig()
agg = SignalAggregator(config)
dfs = {
Timeframe.H1: make_ohlcv(300, seed=42),
Timeframe.M15: make_ohlcv(300, seed=43),
Timeframe.H4: make_ohlcv(300, seed=44),
}
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
owner_msg = tb.format_signal_message(report, spread_detail=True)
channel_msg = tb.format_signal_message(report, spread_detail=False)
assert "Bid/Ask" in owner_msg
assert "Bid/Ask" not in channel_msg
finally:
tb._last_quotes.pop(XAUUSD, None)
@test("telegram_bot: _spread_detail_for routes by chat + internal flag")
def _():
import telegram_bot as tb
saved_channel, saved_flag = tb.CHANNEL_ID, tb._CHANNEL_SPREAD_DETAIL
try:
tb.CHANNEL_ID = -100222
tb._CHANNEL_SPREAD_DETAIL = False # default: channel is public
assert tb._spread_detail_for(111) is True, "owner DM gets raw quotes"
assert tb._spread_detail_for(-100222) is False, \
"public channel must get the number-free variant"
tb._CHANNEL_SPREAD_DETAIL = True # internal-testing channel
assert tb._spread_detail_for(-100222) is True
assert tb._spread_detail_for(111) is True
finally:
tb.CHANNEL_ID, tb._CHANNEL_SPREAD_DETAIL = saved_channel, saved_flag
@test("telegram_bot: log rotation is 3-hourly with 30-day retention")
def _():
import logging.handlers
import telegram_bot as tb
h = tb._file_handler
assert isinstance(h, logging.handlers.TimedRotatingFileHandler)
assert h.when.upper() == "H" and h.interval == 3 * 3600, \
"rotation must fire every 3 hours"
assert h.backupCount == 240, "240 × 3h = 30-day retention"
@test("telegram_bot: candle loop survives a failing scan cycle")
def _():
import asyncio
import telegram_bot as tb
calls = {"n": 0}
async def boom(bot, tf, confirm):
calls["n"] += 1
if calls["n"] == 1:
raise RuntimeError("scan blew up")
tb._autoscan_chats.clear() # let the loop exit after call 2
tb._autoscan_chats.add(1)
try:
with patch.object(tb, "_scan_timeframe", boom), \
patch.object(tb, "_seconds_until_boundary",
lambda interval, buffer=10: 0.0):
asyncio.run(tb._candle_loop(None, Timeframe.M15, 15))
assert calls["n"] == 2, \
"loop must keep firing after an exception, not die on it"
finally:
tb._autoscan_chats.clear()
@test("telegram_bot: no Tiingo key → provider off, message unchanged")
def _():
import telegram_bot as tb
assert tb._tiingo is None, "hermetic env has no Tiingo key"
config = PlatformConfig()
agg = SignalAggregator(config)
dfs = {
Timeframe.H1: make_ohlcv(300, seed=42),
Timeframe.M15: make_ohlcv(300, seed=43),
Timeframe.H4: make_ohlcv(300, seed=44),
}
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
msg = tb.format_signal_message(report)
assert "Bid/Ask" not in msg and "Wide spread" not in msg
# ══════════════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════════════
# SUMMARY # SUMMARY
# ══════════════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════════════
+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}")