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. Claude-Session: https://claude.ai/code/session_01HVRmy6KspMs96o5PTPYFAB
This commit is contained in:
+66
-4
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user