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
+136 -1
View File
@@ -3001,6 +3001,49 @@ def _():
set_app_config(_TEST_APP)
@test("providers: make_data_provider explicit name overrides active")
def _():
from providers import (TiingoDataProvider as _Tii,
TwelveDataProvider, make_data_provider)
# active is twelve_data under _TEST_APP; explicit name wins
with patch.dict(os.environ, {"TIINGO_API_KEY": "k"}):
assert isinstance(make_data_provider(PlatformConfig(), "tiingo"), _Tii)
assert isinstance(make_data_provider(PlatformConfig()), TwelveDataProvider)
try:
make_data_provider(PlatformConfig(), "polygon")
assert False, "unregistered explicit name must raise"
except AppConfigError as e:
assert "polygon" in str(e)
@test("app_config: data_providers.streaming scalar selects the tick stream")
def _():
app = load_app_config(_write_json({
"data_providers": {"active": "oanda", "streaming": "tiingo",
"oanda": {}, "tiingo": {"api_key": "k"}}}))
assert app.streaming_provider == "tiingo"
assert set(app.provider_settings) == {"oanda", "tiingo"}, \
"reserved scalars must not become provider blocks"
app = load_app_config(_write_json(
{"data_providers": {"active": "oanda", "oanda": {}}}))
assert app.streaming_provider is None
@test("app_config: streaming provider must be a string with a settings block")
def _():
for payload in (
{"data_providers": {"active": "oanda", "oanda": {},
"streaming": "polygon"}}, # no block
{"data_providers": {"active": "oanda", "oanda": {},
"streaming": 5}}, # not a string
):
try:
load_app_config(_write_json(payload))
assert False, f"should have raised for {payload}"
except AppConfigError:
pass
@test("config.py: hardcoded twelve_data_api_key field is gone")
def _():
assert not hasattr(PlatformConfig(), "twelve_data_api_key"), \
@@ -3126,6 +3169,55 @@ def _():
tb._autoscan_chats.clear()
@test("telegram_bot: _get_streaming_dp resolves streaming name, else shared")
def _():
import telegram_bot as tb
from providers import TiingoDataProvider as _Tii
set_app_config(AppConfig(
active_provider="twelve_data",
streaming_provider="tiingo",
provider_settings={"twelve_data": {"api_key": "k"},
"tiingo": {"api_key": "k"}}))
tb._streaming_dp = None
tb._shared_dp = None
try:
dp = tb._get_streaming_dp()
assert isinstance(dp, _Tii)
assert dp is not tb._get_shared_dp(), \
"streaming provider must be a separate instance"
# unset → falls back to the shared active provider
set_app_config(_TEST_APP)
tb._streaming_dp = None
tb._shared_dp = None
assert tb._get_streaming_dp() is tb._get_shared_dp()
finally:
set_app_config(_TEST_APP)
tb._streaming_dp = None
tb._shared_dp = None
@test("telegram_bot: ws monitor auto-disables on non-streaming or keyless")
def _():
import asyncio
import telegram_bot as tb
class NoStream:
name = "stub"
supports_streaming = False
class KeylessStream:
name = "stub"
supports_streaming = True
_api_key = None
try:
for stub in (NoStream(), KeylessStream()):
tb._streaming_dp = stub
asyncio.run(tb._ws_monitor(None)) # must return, not connect
finally:
tb._streaming_dp = None
# ══════════════════════════════════════════════════════════════════════
# MODULE 15: Oanda provider / executor / daemon
# ══════════════════════════════════════════════════════════════════════
@@ -4062,7 +4154,7 @@ def _():
def _():
assert TwelveDataProvider.supports_streaming is True
assert _POanda.supports_streaming is False
assert TiingoDataProvider.supports_streaming is False
assert TiingoDataProvider.supports_streaming is True
class Minimal(BaseDataProvider):
name = "minimal"
@@ -4092,6 +4184,49 @@ def _():
assert parse('{"event":"price","symbol":"XAU/USD","price":"abc"}') is None
@test("providers: Tiingo WS quote-message parsing")
def _():
parse = TiingoDataProvider._parse_ws_message
# Field order matches a live-captured frame (2026-08-03):
# ["Q", ticker, ts, bidSize, bid, mid, askSize, ask]
quote = ('{"service":"fx","messageType":"A","data":'
'["Q","xauusd","2026-08-03T17:09:46.084000+00:00",'
'1000000.0,4035.05,4035.1,1000000.0,4035.15]}')
assert parse(quote) == ("xauusd", 4035.1)
# mid null → (bid+ask)/2, ask read from index 7 (index 6 is askSize)
mid_null = ('{"messageType":"A","service":"fx","data":'
'["Q","xauusd","2026-08-03T10:00:00+00:00",'
'1000000,3344.0,null,1000000,3346.0]}')
assert parse(mid_null) == ("xauusd", 3345.0)
# mid and bid null → unusable, skipped
all_null = ('{"messageType":"A","service":"fx","data":'
'["Q","xauusd","2026-08-03T10:00:00+00:00",'
'1000000,null,null,1000000,3346.0]}')
assert parse(all_null) is None
assert parse('{"messageType":"H"}') is None # heartbeat
assert parse('{"messageType":"I","response":{"code":200}}') is None
assert parse('not json{{') is None
assert parse('{"messageType":"A","data":["X","xauusd"]}') is None
# server error (e.g. bad token) must RAISE so the caller's backoff
# applies — Tiingo auths inside the socket, not at the HTTP upgrade
try:
parse('{"messageType":"E","response":'
'{"code":401,"message":"bad token"}}')
assert False, "server error message must raise"
except TiingoDataError:
pass
@test("providers: Tiingo WS ticker mapping round-trips the scanned pairs")
def _():
tp = TiingoDataProvider.__new__(TiingoDataProvider)
for pair in PAIR_INFO:
ticker = tp._to_tiingo_ticker(pair)
assert ticker.islower() and "/" not in ticker
inverse = {tp._to_tiingo_ticker(s): s for s in PAIR_INFO}
assert inverse[ticker] == pair
@test("providers: Oanda date-window fetch omits count and sends from/to")
def _():
captured = {}