diff --git a/binance/async_client.py b/binance/async_client.py index 22002227..d73941c2 100644 --- a/binance/async_client.py +++ b/binance/async_client.py @@ -3,6 +3,7 @@ from typing import Any, Dict, List, Optional, Union from urllib.parse import urlencode, quote import time +import warnings import aiohttp import yarl @@ -1417,6 +1418,14 @@ async def get_open_margin_oco_orders(self, **params): get_open_margin_oco_orders.__doc__ = Client.get_open_margin_oco_orders.__doc__ async def margin_stream_get_listen_key(self): + warnings.warn( + "POST /sapi/v1/userDataStream is deprecated and will be removed on 2026-02-20. " + "Use the WebSocket API subscription method instead (create listenToken via POST /sapi/v1/userListenToken, " + "then subscribe with userDataStream.subscribe.listenToken). " + "The margin_socket() method now uses WebSocket API by default.", + DeprecationWarning, + stacklevel=2 + ) res = await self._request_margin_api( "post", "userDataStream", signed=False, data={} ) @@ -1425,6 +1434,14 @@ async def margin_stream_get_listen_key(self): margin_stream_get_listen_key.__doc__ = Client.margin_stream_get_listen_key.__doc__ async def margin_stream_keepalive(self, listenKey): + warnings.warn( + "PUT /sapi/v1/userDataStream is deprecated and will be removed on 2026-02-20. " + "Use the WebSocket API subscription method instead (create listenToken via POST /sapi/v1/userListenToken, " + "then subscribe with userDataStream.subscribe.listenToken). " + "The margin_socket() method now uses WebSocket API by default.", + DeprecationWarning, + stacklevel=2 + ) params = {"listenKey": listenKey} return await self._request_margin_api( "put", "userDataStream", signed=False, data=params @@ -1433,16 +1450,65 @@ async def margin_stream_keepalive(self, listenKey): margin_stream_keepalive.__doc__ = Client.margin_stream_keepalive.__doc__ async def margin_stream_close(self, listenKey): + warnings.warn( + "DELETE /sapi/v1/userDataStream is deprecated and will be removed on 2026-02-20. " + "Use the WebSocket API subscription method instead (create listenToken via POST /sapi/v1/userListenToken, " + "then subscribe with userDataStream.subscribe.listenToken). " + "The margin_socket() method now uses WebSocket API by default.", + DeprecationWarning, + stacklevel=2 + ) params = {"listenKey": listenKey} return await self._request_margin_api( "delete", "userDataStream", signed=False, data=params ) + async def margin_create_listen_token(self, symbol: Optional[str] = None, is_isolated: bool = False, validity: Optional[int] = None): + """Create a listenToken for margin account user data stream + + https://developers.binance.com/docs/margin_trading/trade-data-stream/Create-Margin-Account-listenToken + + :param symbol: Trading pair symbol (required when is_isolated=True) + :type symbol: str + :param is_isolated: Whether it is isolated margin (default: False for cross-margin) + :type is_isolated: bool + :param validity: Validity in milliseconds (default: 24 hours, max: 24 hours) + :type validity: int + :returns: API response with token and expirationTime + + .. code-block:: python + + { + "token": "6xXxePXwZRjVSHKhzUCCGnmN3fkvMTXru+pYJS8RwijXk9Vcyr3rkwfVOTcP2OkONqciYA", + "expirationTime": 1758792204196 + } + """ + params = {} + if is_isolated: + if not symbol: + raise ValueError("symbol is required when is_isolated=True") + params["symbol"] = symbol + params["isIsolated"] = "true" + if validity is not None: + params["validity"] = validity + + return await self._request_margin_api( + "post", "userListenToken", signed=True, data=params + ) + # Isolated margin margin_stream_close.__doc__ = Client.margin_stream_close.__doc__ async def isolated_margin_stream_get_listen_key(self, symbol): + warnings.warn( + "POST /sapi/v1/userDataStream/isolated is deprecated and will be removed on 2026-02-20. " + "Use the WebSocket API subscription method instead (create listenToken via POST /sapi/v1/userListenToken " + "with isIsolated=true, then subscribe with userDataStream.subscribe.listenToken). " + "The isolated_margin_socket() method now uses WebSocket API by default.", + DeprecationWarning, + stacklevel=2 + ) params = {"symbol": symbol} res = await self._request_margin_api( "post", "userDataStream/isolated", signed=False, data=params @@ -1452,6 +1518,14 @@ async def isolated_margin_stream_get_listen_key(self, symbol): isolated_margin_stream_get_listen_key.__doc__ = Client.isolated_margin_stream_get_listen_key.__doc__ async def isolated_margin_stream_keepalive(self, symbol, listenKey): + warnings.warn( + "PUT /sapi/v1/userDataStream/isolated is deprecated and will be removed on 2026-02-20. " + "Use the WebSocket API subscription method instead (create listenToken via POST /sapi/v1/userListenToken " + "with isIsolated=true, then subscribe with userDataStream.subscribe.listenToken). " + "The isolated_margin_socket() method now uses WebSocket API by default.", + DeprecationWarning, + stacklevel=2 + ) params = {"symbol": symbol, "listenKey": listenKey} return await self._request_margin_api( "put", "userDataStream/isolated", signed=False, data=params @@ -1460,6 +1534,14 @@ async def isolated_margin_stream_keepalive(self, symbol, listenKey): isolated_margin_stream_keepalive.__doc__ = Client.isolated_margin_stream_keepalive.__doc__ async def isolated_margin_stream_close(self, symbol, listenKey): + warnings.warn( + "DELETE /sapi/v1/userDataStream/isolated is deprecated and will be removed on 2026-02-20. " + "Use the WebSocket API subscription method instead (create listenToken via POST /sapi/v1/userListenToken " + "with isIsolated=true, then subscribe with userDataStream.subscribe.listenToken). " + "The isolated_margin_socket() method now uses WebSocket API by default.", + DeprecationWarning, + stacklevel=2 + ) params = {"symbol": symbol, "listenKey": listenKey} return await self._request_margin_api( "delete", "userDataStream/isolated", signed=False, data=params diff --git a/binance/client.py b/binance/client.py index 3b260420..d314b358 100755 --- a/binance/client.py +++ b/binance/client.py @@ -3,6 +3,7 @@ import requests import time +import warnings from urllib.parse import urlencode, quote from .base_client import BaseClient @@ -5667,6 +5668,14 @@ def margin_stream_get_listen_key(self): :raises: BinanceRequestException, BinanceAPIException """ + warnings.warn( + "POST /sapi/v1/userDataStream is deprecated and will be removed on 2026-02-20. " + "Use the WebSocket API subscription method instead (create listenToken via POST /sapi/v1/userListenToken, " + "then subscribe with userDataStream.subscribe.listenToken). " + "The margin_socket() method now uses WebSocket API by default.", + DeprecationWarning, + stacklevel=2 + ) res = self._request_margin_api("post", "userDataStream", signed=False, data={}) return res["listenKey"] @@ -5687,6 +5696,14 @@ def margin_stream_keepalive(self, listenKey): :raises: BinanceRequestException, BinanceAPIException """ + warnings.warn( + "PUT /sapi/v1/userDataStream is deprecated and will be removed on 2026-02-20. " + "Use the WebSocket API subscription method instead (create listenToken via POST /sapi/v1/userListenToken, " + "then subscribe with userDataStream.subscribe.listenToken). " + "The margin_socket() method now uses WebSocket API by default.", + DeprecationWarning, + stacklevel=2 + ) params = {"listenKey": listenKey} return self._request_margin_api( "put", "userDataStream", signed=False, data=params @@ -5709,11 +5726,54 @@ def margin_stream_close(self, listenKey): :raises: BinanceRequestException, BinanceAPIException """ + warnings.warn( + "DELETE /sapi/v1/userDataStream is deprecated and will be removed on 2026-02-20. " + "Use the WebSocket API subscription method instead (create listenToken via POST /sapi/v1/userListenToken, " + "then subscribe with userDataStream.subscribe.listenToken). " + "The margin_socket() method now uses WebSocket API by default.", + DeprecationWarning, + stacklevel=2 + ) params = {"listenKey": listenKey} return self._request_margin_api( "delete", "userDataStream", signed=False, data=params ) + def margin_create_listen_token(self, symbol: Optional[str] = None, is_isolated: bool = False, validity: Optional[int] = None): + """Create a listenToken for margin account user data stream + + https://developers.binance.com/docs/margin_trading/trade-data-stream/Create-Margin-Account-listenToken + + :param symbol: Trading pair symbol (required when is_isolated=True) + :type symbol: str + :param is_isolated: Whether it is isolated margin (default: False for cross-margin) + :type is_isolated: bool + :param validity: Validity in milliseconds (default: 24 hours, max: 24 hours) + :type validity: int + :returns: API response with token and expirationTime + + .. code-block:: python + + { + "token": "6xXxePXwZRjVSHKhzUCCGnmN3fkvMTXru+pYJS8RwijXk9Vcyr3rkwfVOTcP2OkONqciYA", + "expirationTime": 1758792204196 + } + + :raises: BinanceRequestException, BinanceAPIException + """ + params = {} + if is_isolated: + if not symbol: + raise ValueError("symbol is required when is_isolated=True") + params["symbol"] = symbol + params["isIsolated"] = "true" + if validity is not None: + params["validity"] = validity + + return self._request_margin_api( + "post", "userListenToken", signed=True, data=params + ) + # Isolated margin def isolated_margin_stream_get_listen_key(self, symbol): @@ -5739,6 +5799,14 @@ def isolated_margin_stream_get_listen_key(self, symbol): :raises: BinanceRequestException, BinanceAPIException """ + warnings.warn( + "POST /sapi/v1/userDataStream/isolated is deprecated and will be removed on 2026-02-20. " + "Use the WebSocket API subscription method instead (create listenToken via POST /sapi/v1/userListenToken " + "with isIsolated=true, then subscribe with userDataStream.subscribe.listenToken). " + "The isolated_margin_socket() method now uses WebSocket API by default.", + DeprecationWarning, + stacklevel=2 + ) params = {"symbol": symbol} res = self._request_margin_api( "post", "userDataStream/isolated", signed=False, data=params @@ -5764,6 +5832,14 @@ def isolated_margin_stream_keepalive(self, symbol, listenKey): :raises: BinanceRequestException, BinanceAPIException """ + warnings.warn( + "PUT /sapi/v1/userDataStream/isolated is deprecated and will be removed on 2026-02-20. " + "Use the WebSocket API subscription method instead (create listenToken via POST /sapi/v1/userListenToken " + "with isIsolated=true, then subscribe with userDataStream.subscribe.listenToken). " + "The isolated_margin_socket() method now uses WebSocket API by default.", + DeprecationWarning, + stacklevel=2 + ) params = {"symbol": symbol, "listenKey": listenKey} return self._request_margin_api( "put", "userDataStream/isolated", signed=False, data=params @@ -5788,6 +5864,14 @@ def isolated_margin_stream_close(self, symbol, listenKey): :raises: BinanceRequestException, BinanceAPIException """ + warnings.warn( + "DELETE /sapi/v1/userDataStream/isolated is deprecated and will be removed on 2026-02-20. " + "Use the WebSocket API subscription method instead (create listenToken via POST /sapi/v1/userListenToken " + "with isIsolated=true, then subscribe with userDataStream.subscribe.listenToken). " + "The isolated_margin_socket() method now uses WebSocket API by default.", + DeprecationWarning, + stacklevel=2 + ) params = {"symbol": symbol, "listenKey": listenKey} return self._request_margin_api( "delete", "userDataStream/isolated", signed=False, data=params diff --git a/binance/ws/keepalive_websocket.py b/binance/ws/keepalive_websocket.py index 7a50cacb..3f1c3f2e 100644 --- a/binance/ws/keepalive_websocket.py +++ b/binance/ws/keepalive_websocket.py @@ -32,6 +32,9 @@ def __init__( self._subscription_id = None self._listen_key = None # Used for non spot stream types self._uses_ws_api_subscription = False # True when using ws_api + self._listen_token_expiration = ( + None # Expiration time for listenToken-based subscriptions + ) async def __aexit__(self, *args, **kwargs): if self._timer: @@ -40,7 +43,7 @@ async def __aexit__(self, *args, **kwargs): # Clean up subscription if it exists if self._subscription_id is not None: # Unregister the queue from ws_api before unsubscribing - if hasattr(self._client, 'ws_api') and self._client.ws_api: + if hasattr(self._client, "ws_api") and self._client.ws_api: self._client.ws_api.unregister_subscription_queue(self._subscription_id) await self._unsubscribe_from_user_data_stream() if self._uses_ws_api_subscription: @@ -60,25 +63,77 @@ async def _before_connect(self): if self._keepalive_type == "user": # Subscribe via ws_api and register our own queue for events self._subscription_id = await self._subscribe_to_user_data_stream() + if self._subscription_id is None: + raise ValueError( + "Failed to subscribe to user data stream: no subscription ID returned" + ) self._uses_ws_api_subscription = True # Register our queue with ws_api so events get routed to us - self._client.ws_api.register_subscription_queue(self._subscription_id, self._queue) + self._client.ws_api.register_subscription_queue( + self._subscription_id, self._queue + ) self._path = f"user_subscription:{self._subscription_id}" return + if self._keepalive_type == "margin": + # Subscribe to cross-margin via ws_api + self._subscription_id = await self._subscribe_to_margin_data_stream() + if self._subscription_id is None: + raise ValueError( + "Failed to subscribe to margin data stream: no subscription ID returned" + ) + self._uses_ws_api_subscription = True + # Register our queue with ws_api so events get routed to us + self._client.ws_api.register_subscription_queue( + self._subscription_id, self._queue + ) + self._path = f"margin_subscription:{self._subscription_id}" + return + # Check if this is isolated margin (when keepalive_type is a symbol string) + if self._keepalive_type not in [ + "user", + "margin", + "futures", + "coin_futures", + "portfolio_margin", + ]: + # This is isolated margin with symbol as keepalive_type + self._subscription_id = ( + await self._subscribe_to_isolated_margin_data_stream( + self._keepalive_type + ) + ) + if self._subscription_id is None: + raise ValueError( + f"Failed to subscribe to isolated margin data stream for {self._keepalive_type}: no subscription ID returned" + ) + self._uses_ws_api_subscription = True + # Register our queue with ws_api so events get routed to us + self._client.ws_api.register_subscription_queue( + self._subscription_id, self._queue + ) + self._path = f"isolated_margin_subscription:{self._subscription_id}" + return if not self._listen_key: self._listen_key = await self._get_listen_key() self._build_path() async def connect(self): """Override connect to handle ws_api subscriptions differently.""" - if self._keepalive_type == "user": - # For user sockets using ws_api subscription: + # Check if this keepalive type uses ws_api subscription + if self._keepalive_type in ["user", "margin"] or self._keepalive_type not in [ + "futures", + "coin_futures", + "portfolio_margin", + ]: + # For sockets using ws_api subscription: # - Subscribe via ws_api (done in _before_connect) # - Don't create our own websocket connection # - Don't start a read loop (ws_api handles reading) await self._before_connect() - await self._after_connect() - return + # Check if ws_api subscription was actually used + if self._uses_ws_api_subscription: + await self._after_connect() + return # For other keepalive types, use normal connection logic await super().connect() @@ -89,7 +144,9 @@ async def recv(self): res = None while not res: try: - res = await asyncio.wait_for(self._queue.get(), timeout=self.TIMEOUT) + res = await asyncio.wait_for( + self._queue.get(), timeout=self.TIMEOUT + ) except asyncio.TimeoutError: self._log.debug(f"no message in {self.TIMEOUT} seconds") return res @@ -114,9 +171,47 @@ async def _subscribe_to_user_data_stream(self): ) return response.get("subscriptionId") + async def _subscribe_to_margin_data_stream(self): + """Subscribe to cross-margin data stream using WebSocket API with listenToken""" + # Create listenToken for cross-margin + token_response = await self._client.margin_create_listen_token( + is_isolated=False + ) + listen_token = token_response["token"] + self._listen_token_expiration = token_response.get("expirationTime") + + # Subscribe using listenToken + params = { + "id": str(uuid.uuid4()), + "listenToken": listen_token, + } + response = await self._client._ws_api_request( + "userDataStream.subscribe.listenToken", signed=False, params=params + ) + return response.get("subscriptionId") + + async def _subscribe_to_isolated_margin_data_stream(self, symbol: str): + """Subscribe to isolated margin data stream using WebSocket API with listenToken""" + # Create listenToken for isolated margin + token_response = await self._client.margin_create_listen_token( + symbol=symbol, is_isolated=True + ) + listen_token = token_response["token"] + self._listen_token_expiration = token_response.get("expirationTime") + + # Subscribe using listenToken + params = { + "id": str(uuid.uuid4()), + "listenToken": listen_token, + } + response = await self._client._ws_api_request( + "userDataStream.subscribe.listenToken", signed=False, params=params + ) + return response.get("subscriptionId") + async def _unsubscribe_from_user_data_stream(self): """Unsubscribe from user data stream using WebSocket API""" - if self._keepalive_type == "user" and self._subscription_id is not None: + if self._subscription_id is not None: params = { "id": str(uuid.uuid4()), "subscriptionId": self._subscription_id, @@ -146,7 +241,8 @@ async def _get_listen_key(self): async def _keepalive_socket(self): try: - if self._keepalive_type == "user": + # Skip keepalive for ws_api subscriptions (user, margin, isolated margin) + if self._uses_ws_api_subscription: return listen_key = await self._get_listen_key() if listen_key != self._listen_key: