Tiingo WebSocket streaming: re-enable the level-breach monitor

The monitor has been dead since the Oanda feed switch (Oanda's provider
does not stream). New config.json scalar data_providers.streaming selects
a tick-stream provider independent of the candle feed; telegram_bot
resolves it via _get_streaming_dp() (separate instance, candle cache
stays on the active provider) and falls back to the active provider when
unset — behavior is byte-identical to before when the key is absent.

TiingoDataProvider grows the two sanctioned streaming overrides:
supports_streaming=True and iter_ticks() on wss://api.tiingo.com/fx
(mid price only — the no-spread rule extends to the stream). Two
deliberate divergences from the TD reference implementation, both
because Tiingo authenticates inside the socket: server "E" messages
raise TiingoDataError (a swallowed auth error would end the generator
cleanly and reconnect-hammer with zero backoff), and on_connect fires on
the first received message rather than after the subscribe send. Field
order of quote frames was live-verified: ask is data[7], not data[6] as
the docs table suggests (data[6] is askSize).

make_data_provider(config, name=None) now takes an explicit provider
name; keyless streaming providers disable the monitor with one warning
instead of a traceback-per-minute loop.

Tests 266 → 273 (WS parser, ticker round-trip, streaming config scalar,
explicit-name dispatch, streaming-dp resolution, monitor auto-disable).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nsd8HcrV67GbCZXxZEn1Ma
This commit is contained in:
Harwinder Sekhon
2026-08-03 13:11:36 -04:00
parent b936d443b8
commit f0d76021d1
7 changed files with 308 additions and 19 deletions
+14 -1
View File
@@ -106,6 +106,7 @@ class AppConfig:
telegram_channel_id: str = "" # "" = channel feed disabled
alert_min_quality: str = "C"
active_provider: str = "twelve_data"
streaming_provider: Optional[str] = None # None = active provider
provider_settings: Dict[str, Dict[str, Any]] = field(default_factory=dict)
scan_pairs: List[str] = field(default_factory=lambda: [XAUUSD])
scan_timeframes: List[Timeframe] = field(default_factory=lambda: [
@@ -162,7 +163,8 @@ def load_app_config(path: str = CONFIG_PATH) -> AppConfig:
app.active_provider = str(dp.get("active", app.active_provider))
app.provider_settings = {}
for name, block in dp.items():
if name == "active":
# "active" and "streaming" are reserved scalars, not blocks.
if name in ("active", "streaming"):
continue
if not isinstance(block, dict):
raise AppConfigError(
@@ -172,6 +174,17 @@ def load_app_config(path: str = CONFIG_PATH) -> AppConfig:
raise AppConfigError(
f'config.json: data_providers.active "{app.active_provider}" '
"has no matching provider block")
streaming = dp.get("streaming")
if streaming is not None:
if not isinstance(streaming, str):
raise AppConfigError(
'config.json: data_providers.streaming must be a string '
"(a provider name)")
if streaming not in app.provider_settings:
raise AppConfigError(
f'config.json: data_providers.streaming "{streaming}" '
"has no matching provider block")
app.streaming_provider = streaming
scanning = raw.get("scanning", {})
_check_keys(scanning, _KNOWN_SCANNING, '"scanning"')