piker/tests/test_binance.py

252 lines
7.6 KiB
Python
Raw Permalink Normal View History

'''
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)