''' Binance websocket routing and feed-startup regressions. ''' import pytest import trio from piker.brokers._util import DataUnavailable from piker.brokers.binance import feed as binance_feed from piker.brokers.binance.feed import ( get_quote_routes, stream_messages, wait_for_live_quotes, ) from piker.brokers.binance.venues import ( _futes_market_ws, _futes_public_ws, _spot_ws, ) from piker.data.validate import FeedInitializationError def test_usdtm_websocket_routes() -> None: ''' Binance retired its legacy USD-M ``/ws`` route in April 2026. Market and high-frequency public traffic now use separate base paths. Verify piker retains both routes so a future endpoint edit cannot silently send a valid subscription to a socket which never publishes that traffic class. ''' routes: tuple[str, ...] = get_quote_routes( mkt_mode='usdtm_futes', symbols=['NVDAUSDT'], ) assert routes == ( f'{_futes_public_ws}/nvdausdt@bookTicker', f'{_futes_market_ws}/nvdausdt@aggTrade', ) assert get_quote_routes( mkt_mode='spot', symbols=['BTCUSDT'], ) == ( f'{_spot_ws}/btcusdt@bookTicker', f'{_spot_ws}/btcusdt@aggTrade', ) def test_silent_binance_feed_startup_fails() -> None: ''' A retired Binance websocket accepted piker's connection but sent no first data, leaving ``stream_quotes()`` blocked before ``task_status.started()`` and hanging every chart consumer. Keep an empty receive channel open to reproduce a connected but silent socket. The controlled deadline proves startup raises ``DataUnavailable`` with the absent stream classes instead of waiting indefinitely for a first quote. ''' async def main() -> None: send, recv = trio.open_memory_channel(1) async with send, recv: with pytest.raises( DataUnavailable, match='no live data received for NVDAUSDT', ): await wait_for_live_quotes( msg_stream=recv, symbols=['NVDAUSDT'], startup_timeout=0.01, ) trio.run(main) def test_usdtm_aggregate_trade_stream_type() -> None: ''' Binance added ``st`` to USD-M aggregate trades during its June 2026 UM/CM websocket integration. The first valid NVDAUSDT trade then raised ``TypeError`` while constructing piker's strict ``AggTrade`` struct, terminating the feed relay during startup. Feed one representative USD-M frame through ``stream_messages()`` and assert its trade quote is emitted. This proves the new stream discriminator is accepted without weakening parsing of the fields used by downstream consumers. ''' class MockWs: def __init__(self) -> None: self._msgs = iter([{ 'e': 'aggTrade', 'E': 1787782452314, 'a': 8430770, 's': 'NVDAUSDT', 'p': '217.91000', 'q': '0.92', 'nq': '0.92', 'f': 20677382, 'l': 20677382, 'T': 1787782452162, 'm': True, 'st': 1, }]) def __aiter__(self): return self async def __anext__(self) -> dict: try: return next(self._msgs) except StopIteration: raise StopAsyncIteration from None async def main() -> None: msg_gen = stream_messages(MockWs()) typ, quote = await anext(msg_gen) assert typ == 'trade' assert quote['symbol'] == 'NVDAUSDT' assert quote['last'] == 217.91 trio.run(main) def test_usdtm_book_ticker_stream_type() -> None: ''' Binance USD-M book-ticker frames include event and transaction metadata in addition to the update ID and four L1 value fields. Feed the documented USD-M response shape through ``stream_messages()``. The emitted bid and ask ticks prove the parser recognizes the real provider schema rather than a reduced test-only event which could conceal route incompatibility. ''' class MockWs: def __init__(self) -> None: self._msgs = iter([{ 'e': 'bookTicker', 'u': 400900217, 'E': 1568014460893, 'T': 1568014460891, 's': 'BNBUSDT', 'b': '25.35190000', 'B': '31.21000000', 'a': '25.36520000', 'A': '40.66000000', }]) def __aiter__(self): return self async def __anext__(self) -> dict: try: return next(self._msgs) except StopIteration: raise StopAsyncIteration from None async def main() -> None: msg_gen = stream_messages(MockWs()) typ, quote = await anext(msg_gen) assert typ == 'l1' assert quote['symbol'] == 'BNBUSDT' assert quote['ticks'][0] == { 'type': 'bid', 'price': 25.3519, 'size': 31.21, } assert quote['ticks'][2] == { 'type': 'ask', 'price': 25.3652, 'size': 40.66, } trio.run(main) def test_binance_feed_rejects_non_single_symbol_requests() -> None: ''' Piker currently allocates one backend feed task per symbol, but Binance previously accepted empty or multi-symbol requests and either rendered invalid routes or silently retained one market. Exercise both invalid cardinalities against the pure route helper and public backend boundary. The helper ``ValueError`` proves no malformed URLs can be rendered, while contextual ``FeedInitializationError`` errors prove ``stream_quotes()`` rejects each request before market lookup, websocket opening, or task startup. ''' async def main() -> None: for symbols in ( [], ['NVDAUSDT', 'TSLAUSDT'], ): with pytest.raises( ValueError, match='exactly one symbol', ): get_quote_routes( mkt_mode='usdtm_futes', symbols=symbols, ) with pytest.raises( FeedInitializationError, match='exactly one symbol', ): await binance_feed.stream_quotes( send_chan=None, symbols=symbols, feed_is_live=trio.Event(), ) trio.run(main) @pytest.mark.parametrize( 'typ', ['l1', 'trade'], ) def test_first_quote_starts_feed(typ: str) -> None: ''' Waiting independently for both L1 and trade data blocked startup whenever either stream class remained quiet after the other proved the merged Binance route was live. Parameterize the first frame across both stream classes and keep the merged channel open without a second producer. Returning that exact frame under the startup timeout proves either L1-first or trade-first delivery independently establishes feed readiness. ''' async def main() -> None: send, recv = trio.open_memory_channel(1) first_quote: dict = { 'symbol': 'NVDAUSDT', } async with send, recv: await send.send((typ, first_quote)) quote: dict = await wait_for_live_quotes( msg_stream=recv, symbols=['NVDAUSDT'], startup_timeout=0.01, ) assert quote is first_quote trio.run(main)