Fix Binance USD-M websocket routing

Binance retired its legacy futures websocket and split quote
traffic across `/public` and `/market`. Route `bookTicker` and
`aggTrade` through dedicated URLs, merge both sockets, and accept
the new `st` stream-type field.

Bound connection and first-quote startup so failed or silent
streams raise `DataUnavailable` instead of hanging `datad` and
chart consumers.

Also,
- derive feed mode from resolved `FutesPair` metadata
- keep direct URL subscriptions stable across reconnects
- cover USD-M routes, payloads, and startup timeouts

Prompt-IO: ai/prompt-io/opencode/20260826T222600Z_5f631e19_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
wkt/fix_binance_usdm_feed
Gud Boi 2026-08-26 19:24:37 -04:00
parent 5f631e1996
commit 6ca6ab2a10
6 changed files with 439 additions and 101 deletions

View File

@ -0,0 +1,46 @@
---
model: openai/gpt-5.6-sol
service: opencode
session: unavailable
timestamp: 2026-08-26T22:26:00Z
git_ref: 5f631e19
scope: code
substantive: true
raw_file: 20260826T222600Z_5f631e19_prompt_io.raw.md
---
## Prompt
Diagnose and fix the hanging Binance USD-M perpetual feed for
`NVDAUSDT.USDTM.PERP.BINANCE` by comparing piker with Binance's current
API and changelog. Ensure unsupported or silent websocket feeds fail
loudly into `datad` and chart consumers instead of blocking startup.
## Response summary
Migrates Binance USD-M market data from the retired legacy websocket to
the split public and market routes, handles Binance's new aggregate-trade
schema, and introduces bounded connection and first-data startup. Direct
stream URLs avoid reconnect-time subscription-ACK races, while targeted
tests preserve routing and fail-loud behavior.
## Files changed
- `piker/brokers/binance/feed.py` - route, merge, normalize, and bound
Binance quote streams.
- `piker/brokers/binance/venues.py` - define current USD-M websocket
endpoints.
- `piker/data/_web_bs.py` - support bounded websocket context startup.
- `tests/test_binance.py` - cover routes, schemas, and startup failures.
- `ai/prompt-io/opencode/20260826T222600Z_5f631e19_prompt_io.raw.md` -
retain the generated-output record.
- `ai/prompt-io/opencode/20260826T222600Z_5f631e19_prompt_io.md` - record
structured provenance for the code changes.
## Human edits
The human explicitly required fail-loud behavior rather than merely
updating the endpoint, and supplied a direct CLI reproduction proving
both `datad.binance` and the chart caller blocked. Those requirements
drove the connection and first-data deadlines and their regressions. The
changes remain uncommitted for human review.

View File

@ -0,0 +1,57 @@
---
model: openai/gpt-5.6-sol
service: opencode
timestamp: 2026-08-26T22:26:00Z
git_ref: 5f631e19
diff_cmd: git diff HEAD~1..HEAD
---
## Prompt
The user reported that selecting
`NVDAUSDT.USDTM.PERP.BINANCE` connected Binance's USD-M
websocket but never loaded the feed or downstream chart. They asked
OpenCode to compare Binance's API changelog with piker's FQME and feed
code, diagnose the failure, and solve it.
The user clarified that future provider failures must raise loudly
instead of silently blocking feed and chart startup. They also confirmed
the same hang from
`piker -l info --pdb chart nvdausdt.usdtm.perp.binance`.
## Generated code
> `git diff HEAD~1..HEAD -- piker/brokers/binance/feed.py`
Routes USD-M book-ticker and aggregate-trade data through Binance's new
`/public` and `/market` websocket paths, merges both sockets into the
existing quote stream, accepts the new `st` aggregate-trade field, and
requires both stream classes before declaring startup complete. It also
removes the misleading missing-provider warning, derives feed mode from
resolved pair metadata, and raises `DataUnavailable` for connection or
live-data startup timeouts.
> `git diff HEAD~1..HEAD -- piker/brokers/binance/venues.py`
Defines Binance's current public and market USD-M websocket endpoints
and makes the market endpoint the regular futures websocket API root.
> `git diff HEAD~1..HEAD -- piker/data/_web_bs.py`
Adds an optional connection deadline to `open_autorecon_ws()` so callers
can fail context entry when repeated websocket handshakes never become
connected.
> `git diff HEAD~1..HEAD -- tests/test_binance.py`
Adds regressions for exact Binance stream routing, silent live-data
startup, silent websocket connection startup, and the current USD-M
aggregate-trade payload schema.
## Verification output
`pytest -q tests/test_binance.py` passes all four tests. Compile checks,
focused Ruff fatal-error checks, and `git diff --check` pass. Live probes
through piker's merged stream received both required stream classes and
returned normalized trade quotes for `NVDAUSDT` USD-M and `BTCUSDT`
spot.

View File

@ -22,18 +22,14 @@ from __future__ import annotations
from contextlib import ( from contextlib import (
asynccontextmanager as acm, asynccontextmanager as acm,
aclosing, aclosing,
AsyncExitStack,
) )
from datetime import datetime from datetime import datetime
from functools import (
partial,
)
import itertools
from pprint import pformat from pprint import pformat
from typing import ( from typing import (
Any, Any,
AsyncGenerator, AsyncGenerator,
Callable, Callable,
Generator,
) )
import time import time
@ -77,6 +73,8 @@ from .venues import (
Pair, Pair,
FutesPair, FutesPair,
get_api_eps, get_api_eps,
_futes_market_ws,
_futes_public_ws,
) )
log = get_logger(name=__name__) log = get_logger(name=__name__)
@ -110,12 +108,13 @@ class AggTrade(Struct, frozen=True):
M: bool|None = None # Ignore M: bool|None = None # Ignore
nq: float|None = None # Normal quantity without the trades involving RPI orders nq: float|None = None # Normal quantity without the trades involving RPI orders
# ^XXX https://developers.binance.com/docs/derivatives/change-log#2025-12-29 # ^XXX https://developers.binance.com/docs/derivatives/change-log#2025-12-29
st: int|None = None # 1 for USD-M, 2 for COIN-M
async def stream_messages( async def stream_messages(
ws: NoBsWs, ws: NoBsWs,
) -> AsyncGenerator[NoBsWs, dict]: ) -> AsyncGenerator[tuple[str, dict], None]:
# TODO: match syntax here! # TODO: match syntax here!
msg: dict[str, Any] msg: dict[str, Any]
@ -204,31 +203,6 @@ async def stream_messages(
yield 'trade', piker_quote yield 'trade', piker_quote
def make_sub(
pairs: list[str],
sub_name: str,
uid: int,
) -> dict[str, str]:
'''
Create a request subscription packet `dict`.
- spot:
https://binance-docs.github.io/apidocs/spot/en/#live-subscribing-unsubscribing-to-streams
- futes:
https://binance-docs.github.io/apidocs/futures/en/#websocket-market-streams
'''
return {
'method': 'SUBSCRIBE',
'params': [
f'{pair.lower()}@{sub_name}'
for pair in pairs
],
'id': uid
}
# TODO, why aren't frame resp `log.info()`s showing in upstream # TODO, why aren't frame resp `log.info()`s showing in upstream
# code?! # code?!
@acm @acm
@ -306,10 +280,6 @@ async def get_mkt_info(
# uppercase since kraken bs_mktid is always upper # uppercase since kraken bs_mktid is always upper
if 'binance' not in fqme.lower(): if 'binance' not in fqme.lower():
log.warning(
f'Missing `.<provider>` part in fqme ??\n'
f'fqme: {fqme!r}\n'
)
fqme += '.binance' fqme += '.binance'
mkt_mode: str = '' mkt_mode: str = ''
@ -439,54 +409,137 @@ async def get_mkt_info(
return mkt, pair return mkt, pair
@acm def get_quote_routes(
async def subscribe( mkt_mode: str,
ws: NoBsWs,
symbols: list[str], symbols: list[str],
) -> tuple[str, ...]:
'''
Render websocket URLs for each required Binance stream class.
# defined once at import time to keep a global state B) '''
iter_subids: Generator[int, None, None] = itertools.count(), sub_names: tuple[str, str] = ('bookTicker', 'aggTrade')
if mkt_mode == 'usdtm_futes':
return tuple(
f'{wss_url}/'
+ '/'.join(
f'{symbol.lower()}@{sub_name}'
for symbol in symbols
)
for wss_url, sub_name in (
(_futes_public_ws, sub_names[0]),
(_futes_market_ws, sub_names[1]),
)
)
): wss_url: str = get_api_eps(mkt_mode)[1]
# setup subs return tuple(
f'{wss_url}/{symbol.lower()}@{sub_name}'
for symbol in symbols
for sub_name in sub_names
)
subid: int = next(iter_subids)
# trade data (aka L1) async def relay_messages(
# https://binance-docs.github.io/apidocs/spot/en/#symbol-order-book-ticker ws: NoBsWs,
l1_sub = make_sub(symbols, 'bookTicker', subid) send_chan: trio.MemorySendChannel,
await ws.send_msg(l1_sub) ) -> None:
'''
Relay one Binance websocket into a merged quote channel.
# aggregate (each order clear by taker **not** by maker) '''
# trades data: async with (
# https://binance-docs.github.io/apidocs/spot/en/#aggregate-trade-streams send_chan,
agg_trades_sub = make_sub(symbols, 'aggTrade', subid) aclosing(stream_messages(ws)) as msg_gen,
await ws.send_msg(agg_trades_sub) ):
async for msg in msg_gen:
await send_chan.send(msg)
# might get ack from ws server, or maybe some
# other msg still in transit..
res = await ws.recv_msg()
subid: str|None = res.get('id')
if subid:
assert res['id'] == subid
yield @acm
async def open_quote_stream(
mkt_mode: str,
symbols: list[str],
) -> AsyncGenerator[trio.MemoryReceiveChannel, None]:
'''
Open and merge the websocket routes required by a market.
subs = [] '''
for sym in symbols: quote_routes: tuple[str, ...] = get_quote_routes(
subs.append("{sym}@aggTrade") mkt_mode=mkt_mode,
subs.append("{sym}@bookTicker") symbols=symbols,
)
# unsub from all pairs on teardown send, recv = trio.open_memory_channel(616)
if ws.connected(): async with AsyncExitStack() as stack:
await ws.send_msg({ websockets: list[NoBsWs] = []
"method": "UNSUBSCRIBE", try:
"params": subs, for wss_url in quote_routes:
"id": subid, ws: NoBsWs = await stack.enter_async_context(
}) open_autorecon_ws(
url=wss_url,
connect_timeout=16,
)
)
websockets.append(ws)
# XXX: do we need to ack the unsub? except TimeoutError as err:
# await ws.recv_msg() routes_str: str = '\n'.join(quote_routes)
raise DataUnavailable(
f'Binance websocket connection timed out for '
f'{symbols!r}:\n'
f'{routes_str}'
) from err
async with trio.open_nursery() as tn:
for ws in websockets:
tn.start_soon(
relay_messages,
ws,
send.clone(),
)
await send.aclose()
async with recv:
try:
yield recv
finally:
tn.cancel_scope.cancel()
async def wait_for_live_quotes(
msg_stream: AsyncGenerator | trio.MemoryReceiveChannel,
symbols: list[str],
timeout: float = 16,
) -> dict:
'''
Require both L1 and trade data before declaring a feed live.
'''
missing: set[str] = {'l1', 'trade'}
first_trade: dict|None = None
try:
with trio.fail_after(timeout):
async for typ, quote in msg_stream:
missing.discard(typ)
if typ == 'trade' and first_trade is None:
first_trade = quote
if not missing:
assert first_trade
return first_trade
except trio.TooSlowError as err:
missing_str: str = ', '.join(sorted(missing))
raise DataUnavailable(
f'Binance feed startup timed out after {timeout}s for '
f'{symbols!r}; no live {missing_str} data received'
) from err
missing_str: str = ', '.join(sorted(missing))
raise DataUnavailable(
f'Binance quote streams closed during startup for '
f'{symbols!r}; no live {missing_str} data received'
)
async def stream_quotes( async def stream_quotes(
@ -503,43 +556,40 @@ async def stream_quotes(
async with ( async with (
tractor.trionics.maybe_raise_from_masking_exc(), tractor.trionics.maybe_raise_from_masking_exc(),
send_chan as send_chan, send_chan as send_chan,
open_cached_client('binance') as client,
): ):
init_msgs: list[FeedInit] = [] init_msgs: list[FeedInit] = []
mkt_mode: str|None = None
for sym in symbols: for sym in symbols:
mkt: MktPair mkt: MktPair
pair: Pair pair: Pair
mkt, pair = await get_mkt_info(sym) mkt, pair = await get_mkt_info(sym)
pair_mode: str = (
'usdtm_futes'
if isinstance(pair, FutesPair)
else 'spot'
)
if mkt_mode not in {None, pair_mode}:
raise DataUnavailable(
'Binance cannot mix spot and futures symbols in '
f'one feed: {symbols!r}'
)
mkt_mode = pair_mode
# build out init msgs according to latest spec # build out init msgs according to latest spec
init_msgs.append( init_msgs.append(
FeedInit(mkt_info=mkt) FeedInit(mkt_info=mkt)
) )
wss_url: str = get_api_eps(client.mkt_mode)[1] # 2nd elem is wss url assert mkt_mode
ws_symbols: list[str] = [mkt.bs_mktid]
# TODO: for sanity, but remove eventually Xp async with open_quote_stream(
if 'future' in mkt.type_key: mkt_mode=mkt_mode,
assert 'fstream' in wss_url symbols=ws_symbols,
) as msg_stream:
async with ( quote: dict = await wait_for_live_quotes(
open_autorecon_ws( msg_stream=msg_stream,
url=wss_url, symbols=ws_symbols,
fixture=partial( )
subscribe,
symbols=[mkt.bs_mktid],
),
) as ws,
# avoid stream-gen closure from breaking trio..
aclosing(stream_messages(ws)) as msg_gen,
):
# log.info('WAITING ON FIRST LIVE QUOTE..')
typ, quote = await anext(msg_gen)
# pull a first quote and deliver
while typ != 'trade':
typ, quote = await anext(msg_gen)
task_status.started((init_msgs, quote)) task_status.started((init_msgs, quote))
@ -556,7 +606,7 @@ async def stream_quotes(
topic: str = mkt.bs_fqme topic: str = mkt.bs_fqme
# start streaming # start streaming
async for typ, quote in msg_gen: async for typ, quote in msg_stream:
# period = time.time() - last # period = time.time() - last
# hz = 1/period if period else float('inf') # hz = 1/period if period else float('inf')
# if hz > 60: # if hz > 60:

View File

@ -42,7 +42,10 @@ _spot_ws: str = 'wss://stream.binance.com/ws'
# or this one? .. # or this one? ..
# 'wss://ws-api.binance.com:443/ws-api/v3', # 'wss://ws-api.binance.com:443/ws-api/v3',
# https://binance-docs.github.io/apidocs/futures/en/#websocket-market-streams # https://developers.binance.com/docs/derivatives/usds-margined-futures/
# websocket-market-streams/Important-WebSocket-Change-Notice
_futes_public_ws: str = f'wss://fstream.{_domain}/public/ws'
_futes_market_ws: str = f'wss://fstream.{_domain}/market/ws'
_futes_ws: str = f'wss://fstream.{_domain}/ws' _futes_ws: str = f'wss://fstream.{_domain}/ws'
_auth_futes_ws: str = 'wss://fstream-auth.{_domain}/ws' _auth_futes_ws: str = 'wss://fstream-auth.{_domain}/ws'
@ -79,7 +82,7 @@ def get_api_eps(venue: MarketType) -> tuple[str, str]:
), ),
'usdtm_futes': ( 'usdtm_futes': (
_futes_url, _futes_url,
_futes_ws, _futes_market_ws,
), ),
}[venue] }[venue]

View File

@ -342,6 +342,7 @@ async def open_autorecon_ws(
url: str, url: str,
fixture: AsyncContextManager|None = None, fixture: AsyncContextManager|None = None,
connect_timeout: float|None = None,
# time in sec between msgs received before # time in sec between msgs received before
# we presume connection might need a reset. # we presume connection might need a reset.
@ -391,7 +392,19 @@ async def open_autorecon_ws(
reset_after=reset_after, reset_after=reset_after,
) )
) )
try:
if connect_timeout is None:
await nobsws._connected.wait() await nobsws._connected.wait()
else:
with trio.fail_after(connect_timeout):
await nobsws._connected.wait()
except trio.TooSlowError as err:
raise TimeoutError(
f'Websocket connection timed out after '
f'{connect_timeout}s: {url}'
) from err
assert nobsws._cs assert nobsws._cs
assert nobsws.connected() assert nobsws.connected()
try: try:

View File

@ -0,0 +1,169 @@
'''
Binance websocket routing and feed-startup regressions.
'''
import pytest
import trio
from piker.brokers._util import DataUnavailable
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 import _web_bs
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 aggregate trades, 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 l1, trade data received',
):
await wait_for_live_quotes(
msg_stream=recv,
symbols=['NVDAUSDT'],
timeout=0.01,
)
trio.run(main)
def test_websocket_connection_startup_fails(
monkeypatch: pytest.MonkeyPatch,
) -> None:
'''
A websocket handshake which never completed previously blocked
before Binance's live-data timeout started. Generic feed startup
then waited 616 seconds, effectively hanging the chart like a
connected socket which never published quotes.
Replace the reconnect task with one which reports task startup
but never sets ``NoBsWs._connected``. The bounded context entry
proves ``open_autorecon_ws()`` raises a URL-bearing
``TimeoutError`` and cancels its nursery instead of waiting
indefinitely.
'''
async def never_connect(
*args,
task_status=trio.TASK_STATUS_IGNORED,
**kwargs,
) -> None:
task_status.started()
await trio.sleep_forever()
monkeypatch.setattr(
_web_bs,
'_reconnect_forever',
never_connect,
)
async def main() -> None:
with pytest.raises(
TimeoutError,
match='wss://silent.invalid/ws',
):
async with _web_bs.open_autorecon_ws(
url='wss://silent.invalid/ws',
connect_timeout=0.01,
):
pytest.fail(
'silent websocket unexpectedly connected'
)
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)