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
+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.
Precedence for every setting: env var → config.json → code default.
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).
A repo-root .env file (gitignored) is loaded at import via setdefault,
so it behaves exactly like real environment variables except that a
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
the config.json.example defaults, every produced PlatformConfig is
@@ -34,6 +37,40 @@ logger = logging.getLogger(__name__)
CONFIG_PATH = os.path.join(
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_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")
_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_PAIR_KEYS = {"blocked_hours_utc"}
@@ -57,6 +95,11 @@ class AppConfig:
telegram_chat_id: str = ""
telegram_channel_id: str = "" # "" = channel feed disabled
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"
provider_settings: Dict[str, Dict[str, Any]] = field(default_factory=dict)
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"not in {_VALID_QUALITIES}")
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")
if dp is not None:
@@ -227,6 +275,20 @@ def provider_api_key(app: Optional[AppConfig] = None) -> str:
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 ────────────────────────────────────────────
def apply_pair_overrides(