252 lines
7.6 KiB
Python
252 lines
7.6 KiB
Python
'''
|
|
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)
|