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
+34 -3
View File
@@ -12,7 +12,9 @@ Auto-scan: Starts automatically on boot if TELEGRAM_CHAT_ID is set.
M15 fires at :00/:15/:30/:45 + 10s buffer.
H1 fires at :00 + 10s buffer.
H4 fires at :00 every 4 hours + 10s buffer.
WebSocket: Streams live ticks from TwelveData for XAU/USD.
WebSocket: Streams live ticks from the streaming provider — config.json
`data_providers.streaming` when set (e.g. tiingo), else the
active provider if it streams; auto-disabled otherwise.
When price crosses a key structure level (swing S/R, BB,
EMA200) cached from the last boundary scan, triggers an
immediate M15 scan and sends a Level Break Alert.
@@ -203,6 +205,27 @@ def _get_shared_dp() -> BaseDataProvider:
return _shared_dp
_streaming_dp: Optional[BaseDataProvider] = None
def _get_streaming_dp() -> BaseDataProvider:
"""Provider for the tick stream: config.json data_providers.streaming
when set (a SEPARATE instance — the candle cache stays on the active
provider), else the shared active provider. Reads app config at call
time, not the import-time _APP snapshot, so tests can swap it via
set_app_config()."""
global _streaming_dp
if _streaming_dp is None:
app = get_app_config()
if app.streaming_provider is None or \
app.streaming_provider == app.active_provider:
_streaming_dp = _get_shared_dp()
else:
_streaming_dp = make_data_provider(
PlatformConfig(), app.streaming_provider)
return _streaming_dp
# ── Signal formatting ─────────────────────────────────────────────────
_SIGNAL_EMOJI = {
@@ -755,12 +778,19 @@ async def _ws_monitor(bot) -> None:
"""Level-breach tick monitor. The provider owns the connection and
protocol (`iter_ticks`); this loop owns reconnect/backoff and breach
dispatch. Providers without streaming quotes simply disable it."""
dp = _get_shared_dp()
dp = _get_streaming_dp()
if not dp.supports_streaming:
logger.info(
"Data provider %r has no streaming quotes — "
"level-breach monitor disabled", dp.name)
return
if getattr(dp, "_api_key", "unused") is None:
# Guard, not a traceback loop: without a key every connect
# attempt would raise and re-log a full traceback every ≤60s.
logger.warning(
"Streaming provider %r has no API key — "
"level-breach monitor disabled", dp.name)
return
backoff = 1
while _feed_active():
@@ -951,7 +981,8 @@ def _status_text() -> str:
lines.append(f"📢 Channel feed: *on* (`{CHANNEL_ID}`)")
if _ws_task and not _ws_task.done():
lines.append("📡 WebSocket: *connected*")
provider = _streaming_dp.name if _streaming_dp else "?"
lines.append(f"📡 WebSocket: *connected* ({provider})")
else:
lines.append("❌ WebSocket: *disconnected*")