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:
+136
-1
@@ -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 = {}
|
||||
|
||||
Reference in New Issue
Block a user