unicex 0.13.17__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. unicex/__init__.py +200 -0
  2. unicex/_abc/__init__.py +11 -0
  3. unicex/_abc/exchange_info.py +216 -0
  4. unicex/_abc/uni_client.py +329 -0
  5. unicex/_abc/uni_websocket_manager.py +294 -0
  6. unicex/_base/__init__.py +9 -0
  7. unicex/_base/client.py +214 -0
  8. unicex/_base/websocket.py +261 -0
  9. unicex/binance/__init__.py +27 -0
  10. unicex/binance/adapter.py +202 -0
  11. unicex/binance/client.py +1577 -0
  12. unicex/binance/exchange_info.py +62 -0
  13. unicex/binance/uni_client.py +188 -0
  14. unicex/binance/uni_websocket_manager.py +166 -0
  15. unicex/binance/user_websocket.py +186 -0
  16. unicex/binance/websocket_manager.py +912 -0
  17. unicex/bitget/__init__.py +27 -0
  18. unicex/bitget/adapter.py +188 -0
  19. unicex/bitget/client.py +2514 -0
  20. unicex/bitget/exchange_info.py +48 -0
  21. unicex/bitget/uni_client.py +198 -0
  22. unicex/bitget/uni_websocket_manager.py +275 -0
  23. unicex/bitget/user_websocket.py +7 -0
  24. unicex/bitget/websocket_manager.py +232 -0
  25. unicex/bybit/__init__.py +27 -0
  26. unicex/bybit/adapter.py +208 -0
  27. unicex/bybit/client.py +1876 -0
  28. unicex/bybit/exchange_info.py +53 -0
  29. unicex/bybit/uni_client.py +200 -0
  30. unicex/bybit/uni_websocket_manager.py +291 -0
  31. unicex/bybit/user_websocket.py +7 -0
  32. unicex/bybit/websocket_manager.py +339 -0
  33. unicex/enums.py +273 -0
  34. unicex/exceptions.py +64 -0
  35. unicex/extra.py +335 -0
  36. unicex/gate/__init__.py +27 -0
  37. unicex/gate/adapter.py +178 -0
  38. unicex/gate/client.py +1667 -0
  39. unicex/gate/exchange_info.py +55 -0
  40. unicex/gate/uni_client.py +214 -0
  41. unicex/gate/uni_websocket_manager.py +269 -0
  42. unicex/gate/user_websocket.py +7 -0
  43. unicex/gate/websocket_manager.py +513 -0
  44. unicex/hyperliquid/__init__.py +27 -0
  45. unicex/hyperliquid/adapter.py +261 -0
  46. unicex/hyperliquid/client.py +2315 -0
  47. unicex/hyperliquid/exchange_info.py +119 -0
  48. unicex/hyperliquid/uni_client.py +325 -0
  49. unicex/hyperliquid/uni_websocket_manager.py +269 -0
  50. unicex/hyperliquid/user_websocket.py +7 -0
  51. unicex/hyperliquid/websocket_manager.py +393 -0
  52. unicex/mapper.py +111 -0
  53. unicex/mexc/__init__.py +27 -0
  54. unicex/mexc/_spot_ws_proto/PrivateAccountV3Api_pb2.py +38 -0
  55. unicex/mexc/_spot_ws_proto/PrivateDealsV3Api_pb2.py +38 -0
  56. unicex/mexc/_spot_ws_proto/PrivateOrdersV3Api_pb2.py +38 -0
  57. unicex/mexc/_spot_ws_proto/PublicAggreBookTickerV3Api_pb2.py +38 -0
  58. unicex/mexc/_spot_ws_proto/PublicAggreDealsV3Api_pb2.py +40 -0
  59. unicex/mexc/_spot_ws_proto/PublicAggreDepthsV3Api_pb2.py +40 -0
  60. unicex/mexc/_spot_ws_proto/PublicBookTickerBatchV3Api_pb2.py +38 -0
  61. unicex/mexc/_spot_ws_proto/PublicBookTickerV3Api_pb2.py +38 -0
  62. unicex/mexc/_spot_ws_proto/PublicDealsV3Api_pb2.py +40 -0
  63. unicex/mexc/_spot_ws_proto/PublicFuture_pb2.py +103 -0
  64. unicex/mexc/_spot_ws_proto/PublicIncreaseDepthsBatchV3Api_pb2.py +38 -0
  65. unicex/mexc/_spot_ws_proto/PublicIncreaseDepthsV3Api_pb2.py +40 -0
  66. unicex/mexc/_spot_ws_proto/PublicLimitDepthsV3Api_pb2.py +40 -0
  67. unicex/mexc/_spot_ws_proto/PublicMiniTickerV3Api_pb2.py +38 -0
  68. unicex/mexc/_spot_ws_proto/PublicMiniTickersV3Api_pb2.py +38 -0
  69. unicex/mexc/_spot_ws_proto/PublicSpotKlineV3Api_pb2.py +38 -0
  70. unicex/mexc/_spot_ws_proto/PushDataV3ApiWrapper_pb2.py +38 -0
  71. unicex/mexc/_spot_ws_proto/__init__.py +335 -0
  72. unicex/mexc/adapter.py +239 -0
  73. unicex/mexc/client.py +846 -0
  74. unicex/mexc/exchange_info.py +47 -0
  75. unicex/mexc/uni_client.py +211 -0
  76. unicex/mexc/uni_websocket_manager.py +269 -0
  77. unicex/mexc/user_websocket.py +7 -0
  78. unicex/mexc/websocket_manager.py +456 -0
  79. unicex/okx/__init__.py +27 -0
  80. unicex/okx/adapter.py +150 -0
  81. unicex/okx/client.py +2864 -0
  82. unicex/okx/exchange_info.py +47 -0
  83. unicex/okx/uni_client.py +202 -0
  84. unicex/okx/uni_websocket_manager.py +269 -0
  85. unicex/okx/user_websocket.py +7 -0
  86. unicex/okx/websocket_manager.py +743 -0
  87. unicex/types.py +164 -0
  88. unicex/utils.py +218 -0
  89. unicex-0.13.17.dist-info/METADATA +243 -0
  90. unicex-0.13.17.dist-info/RECORD +93 -0
  91. unicex-0.13.17.dist-info/WHEEL +5 -0
  92. unicex-0.13.17.dist-info/licenses/LICENSE +28 -0
  93. unicex-0.13.17.dist-info/top_level.txt +1 -0
@@ -0,0 +1,62 @@
1
+ __all__ = ["ExchangeInfo"]
2
+
3
+
4
+ import aiohttp
5
+
6
+ from unicex._abc import IExchangeInfo
7
+ from unicex.types import TickerInfoItem
8
+
9
+ from .client import Client
10
+
11
+
12
+ class ExchangeInfo(IExchangeInfo):
13
+ """Предзагружает информацию о тикерах для биржи Binance."""
14
+
15
+ exchange_name = "Binance"
16
+ """Название биржи, на которой работает класс."""
17
+
18
+ @classmethod
19
+ async def _load_spot_exchange_info(cls, session: aiohttp.ClientSession) -> None:
20
+ """Загружает информацию о бирже для спотового рынка."""
21
+ exchange_info = await Client(session).exchange_info()
22
+ tickers_info: dict[str, TickerInfoItem] = {}
23
+ for symbol_info in exchange_info["symbols"]:
24
+ filters = {
25
+ flt["filterType"]: flt
26
+ for flt in symbol_info.get("filters", [])
27
+ if "filterType" in flt
28
+ }
29
+ price_filter = filters["PRICE_FILTER"]
30
+ lot_size_filter = filters["LOT_SIZE"]
31
+ tickers_info[symbol_info["symbol"]] = TickerInfoItem(
32
+ tick_step=float(price_filter["tickSize"]),
33
+ tick_precision=None,
34
+ size_step=float(lot_size_filter["stepSize"]),
35
+ size_precision=None,
36
+ contract_size=1,
37
+ )
38
+
39
+ cls._tickers_info = tickers_info
40
+
41
+ @classmethod
42
+ async def _load_futures_exchange_info(cls, session: aiohttp.ClientSession) -> None:
43
+ """Загружает информацию о бирже для фьючерсного рынка."""
44
+ exchange_info = await Client(session).futures_exchange_info()
45
+ tickers_info: dict[str, TickerInfoItem] = {}
46
+ for symbol_info in exchange_info["symbols"]:
47
+ filters = {
48
+ flt["filterType"]: flt
49
+ for flt in symbol_info.get("filters", [])
50
+ if "filterType" in flt
51
+ }
52
+ price_filter = filters["PRICE_FILTER"]
53
+ lot_size_filter = filters["LOT_SIZE"]
54
+ tickers_info[symbol_info["symbol"]] = TickerInfoItem(
55
+ tick_step=float(price_filter["tickSize"]),
56
+ tick_precision=None,
57
+ size_step=float(lot_size_filter["stepSize"]),
58
+ size_precision=None,
59
+ contract_size=1,
60
+ )
61
+
62
+ cls._futures_tickers_info = tickers_info
@@ -0,0 +1,188 @@
1
+ __all__ = ["UniClient"]
2
+
3
+
4
+ from typing import overload
5
+
6
+ from unicex._abc import IUniClient
7
+ from unicex.enums import Exchange, MarketType, Timeframe
8
+ from unicex.types import KlineDict, OpenInterestItem, TickerDailyDict
9
+
10
+ from .adapter import Adapter
11
+ from .client import Client
12
+
13
+
14
+ class UniClient(IUniClient[Client]):
15
+ """Унифицированный клиент для работы с Binance API."""
16
+
17
+ @property
18
+ def _client_cls(self) -> type[Client]:
19
+ """Возвращает класс клиента для Binance.
20
+
21
+ Возвращает:
22
+ type[Client]: Класс клиента для Binance.
23
+ """
24
+ return Client
25
+
26
+ async def tickers(self, only_usdt: bool = True) -> list[str]:
27
+ """Возвращает список тикеров.
28
+
29
+ Параметры:
30
+ only_usdt (bool): Если True, возвращает только тикеры в паре к USDT.
31
+
32
+ Возвращает:
33
+ list[str]: Список тикеров.
34
+ """
35
+ raw_data = await self._client.ticker_price()
36
+ return Adapter.tickers(raw_data=raw_data, only_usdt=only_usdt) # type: ignore | raw_data is list[dict] if symbol param is not ommited
37
+
38
+ async def futures_tickers(self, only_usdt: bool = True) -> list[str]:
39
+ """Возвращает список тикеров.
40
+
41
+ Параметры:
42
+ only_usdt (bool): Если True, возвращает только тикеры в паре к USDT.
43
+
44
+ Возвращает:
45
+ list[str]: Список тикеров.
46
+ """
47
+ raw_data = await self._client.futures_ticker_price()
48
+ return Adapter.tickers(raw_data=raw_data, only_usdt=only_usdt) # type: ignore | raw_data is list[dict] if symbol param is not ommited
49
+
50
+ async def last_price(self) -> dict[str, float]:
51
+ """Возвращает последнюю цену для каждого тикера.
52
+
53
+ Возвращает:
54
+ dict[str, float]: Словарь с последними ценами для каждого тикера.
55
+ """
56
+ raw_data = await self._client.ticker_price()
57
+ return Adapter.last_price(raw_data) # type: ignore | raw_data is list[dict] if symbol param is not ommited
58
+
59
+ async def futures_last_price(self) -> dict[str, float]:
60
+ """Возвращает последнюю цену для каждого тикера.
61
+
62
+ Возвращает:
63
+ dict[str, float]: Словарь с последними ценами для каждого тикера.
64
+ """
65
+ raw_data = await self._client.futures_ticker_price()
66
+ return Adapter.last_price(raw_data) # type: ignore | raw_data is list[dict] if symbol param is not ommited
67
+
68
+ async def ticker_24hr(self) -> TickerDailyDict:
69
+ """Возвращает статистику за последние 24 часа для каждого тикера.
70
+
71
+ Возвращает:
72
+ TickerDailyDict: Словарь с статистикой за последние 24 часа для каждого тикера.
73
+ """
74
+ raw_data = await self._client.ticker_24hr()
75
+ return Adapter.ticker_24hr(raw_data=raw_data) # type: ignore | raw_data is list[dict] if symbol param is not ommited
76
+
77
+ async def futures_ticker_24hr(self) -> TickerDailyDict:
78
+ """Возвращает статистику за последние 24 часа для каждого тикера.
79
+
80
+ Возвращает:
81
+ TickerDailyDict: Словарь с статистикой за последние 24 часа для каждого тикера.
82
+ """
83
+ raw_data = await self._client.futures_ticker_24hr()
84
+ return Adapter.ticker_24hr(raw_data=raw_data) # type: ignore | raw_data is list[dict] if symbol param is not ommited
85
+
86
+ async def klines(
87
+ self,
88
+ symbol: str,
89
+ interval: Timeframe | str,
90
+ limit: int | None = None,
91
+ start_time: int | None = None,
92
+ end_time: int | None = None,
93
+ ) -> list[KlineDict]:
94
+ """Возвращает список свечей для тикера.
95
+
96
+ Параметры:
97
+ symbol (str): Название тикера.
98
+ limit (int | None): Количество свечей.
99
+ interval (Timeframe | str): Таймфрейм свечей.
100
+ start_time (int | None): Время начала периода в миллисекундах.
101
+ end_time (int | None): Время окончания периода в миллисекундах.
102
+
103
+ Возвращает:
104
+ list[KlineDict]: Список свечей для тикера.
105
+ """
106
+ interval = (
107
+ interval.to_exchange_format(Exchange.BINANCE, MarketType.SPOT)
108
+ if isinstance(interval, Timeframe)
109
+ else interval
110
+ )
111
+ raw_data = await self._client.klines(
112
+ symbol=symbol,
113
+ interval=interval,
114
+ limit=limit,
115
+ start_time=start_time,
116
+ end_time=end_time,
117
+ )
118
+ return Adapter.klines(raw_data=raw_data, symbol=symbol)
119
+
120
+ async def futures_klines(
121
+ self,
122
+ symbol: str,
123
+ interval: Timeframe | str,
124
+ limit: int | None = None,
125
+ start_time: int | None = None,
126
+ end_time: int | None = None,
127
+ ) -> list[KlineDict]:
128
+ """Возвращает список свечей для тикера.
129
+
130
+ Параметры:
131
+ symbol (str): Название тикера.
132
+ limit (int | None): Количество свечей.
133
+ interval (Timeframe | str): Таймфрейм свечей.
134
+ start_time (int | None): Время начала периода в миллисекундах.
135
+ end_time (int | None): Время окончания периода в миллисекундах.
136
+
137
+ Возвращает:
138
+ list[KlineDict]: Список свечей для тикера.
139
+ """
140
+ interval = (
141
+ interval.to_exchange_format(Exchange.BINANCE, MarketType.FUTURES)
142
+ if isinstance(interval, Timeframe)
143
+ else interval
144
+ )
145
+ raw_data = await self._client.futures_klines(
146
+ symbol=symbol,
147
+ interval=interval,
148
+ limit=limit,
149
+ start_time=start_time,
150
+ end_time=end_time,
151
+ )
152
+ return Adapter.klines(raw_data=raw_data, symbol=symbol)
153
+
154
+ @overload
155
+ async def funding_rate(self, symbol: str) -> float: ...
156
+
157
+ @overload
158
+ async def funding_rate(self, symbol: None) -> dict[str, float]: ...
159
+
160
+ @overload
161
+ async def funding_rate(self) -> dict[str, float]: ...
162
+
163
+ async def funding_rate(self, symbol: str | None = None) -> dict[str, float] | float:
164
+ """Возвращает ставку финансирования для тикера или всех тикеров, если тикер не указан.
165
+
166
+ - Параметры:
167
+ symbol (`str | None`): Название тикера (Опционально).
168
+
169
+ Возвращает:
170
+ `dict[str, float] | float`: Ставка финансирования для тикера или словарь со ставками для всех тикеров.
171
+ """
172
+ raw_data = await self._client.futures_mark_price(symbol=symbol)
173
+ adapted_data = Adapter.funding_rate(raw_data if isinstance(raw_data, list) else [raw_data]) # type: ignore[arg-type]
174
+ return adapted_data[symbol] if symbol else adapted_data
175
+
176
+ async def open_interest(self, symbol: str = None) -> OpenInterestItem: # type: ignore[reportArgumentType] | We should provide our exception message
177
+ """Возвращает объем открытого интереса для тикера.
178
+
179
+ Параметры:
180
+ symbol (`str`): Название тикера.
181
+
182
+ Возвращает:
183
+ `OpenInterestItem`: Словарь со временем и объемом открытого интереса в монетах.
184
+ """
185
+ if not symbol:
186
+ raise ValueError("Symbol is required for binance open interest")
187
+ raw_data = await self._client.open_interest(symbol=symbol)
188
+ return Adapter.open_interest(raw_data)
@@ -0,0 +1,166 @@
1
+ __all__ = ["UniWebsocketManager"]
2
+
3
+ from collections.abc import Awaitable, Callable
4
+ from typing import Any
5
+
6
+ from unicex._abc import IUniWebsocketManager
7
+ from unicex._base import Websocket
8
+ from unicex.enums import Exchange, MarketType, Timeframe
9
+ from unicex.types import LoggerLike
10
+
11
+ from .adapter import Adapter
12
+ from .client import Client
13
+ from .uni_client import UniClient
14
+ from .websocket_manager import WebsocketManager
15
+
16
+ type CallbackType = Callable[[Any], Awaitable[None]]
17
+
18
+
19
+ class UniWebsocketManager(IUniWebsocketManager):
20
+ """Реализация менеджера асинхронных унифицированных вебсокетов для биржи Binance."""
21
+
22
+ def __init__(
23
+ self, client: Client | UniClient | None = None, logger: LoggerLike | None = None
24
+ ) -> None:
25
+ """Инициализирует унифицированный менеджер вебсокетов.
26
+
27
+ Параметры:
28
+ client (`Client | UniClient | None`): Клиент Binance или унифицированный клиент. Нужен для подключения к приватным топикам.
29
+ logger (`LoggerLike | None`): Логгер для записи логов.
30
+ """
31
+ super().__init__(client=client, logger=logger)
32
+ self._websocket_manager = WebsocketManager(self._client) # type: ignore
33
+ self._adapter = Adapter()
34
+
35
+ def klines(
36
+ self,
37
+ callback: CallbackType,
38
+ timeframe: Timeframe,
39
+ symbol: str | None = None,
40
+ symbols: list[str] | None = None,
41
+ ) -> Websocket:
42
+ """Создаёт вебсокет для получения свечей на споте с унификацией сообщений.
43
+
44
+ Параметры:
45
+ callback (`CallbackType`): Асинхронная функция обработки адаптированных сообщений.
46
+ timeframe (`Timeframe`): Временной интервал свечей (унифицированный).
47
+ symbol (`str | None`): Один символ для подписки.
48
+ symbols (`list[str] | None`): Список символов для мультиплекс‑подключения.
49
+
50
+ Должен быть указан либо `symbol`, либо `symbols`.
51
+
52
+ Возвращает:
53
+ `Websocket`: Экземпляр вебсокета для управления соединением.
54
+ """
55
+ wrapper = self._make_wrapper(self._adapter.klines_message, callback)
56
+ return self._websocket_manager.klines(
57
+ callback=wrapper,
58
+ symbol=symbol,
59
+ symbols=symbols,
60
+ interval=timeframe.to_exchange_format(Exchange.BINANCE, MarketType.SPOT),
61
+ )
62
+
63
+ def futures_klines(
64
+ self,
65
+ callback: CallbackType,
66
+ timeframe: Timeframe,
67
+ symbol: str | None = None,
68
+ symbols: list[str] | None = None,
69
+ ) -> Websocket:
70
+ """Создаёт вебсокет для получения свечей на фьючерсах с унификацией сообщений.
71
+
72
+ Параметры:
73
+ callback (`CallbackType`): Асинхронная функция обработки адаптированных сообщений.
74
+ timeframe (`Timeframe`): Временной интервал свечей (унифицированный).
75
+ symbol (`str | None`): Один символ для подписки.
76
+ symbols (`list[str] | None`): Список символов для мультиплекс‑подключения.
77
+
78
+ Должен быть указан либо `symbol`, либо `symbols`.
79
+
80
+ Возвращает:
81
+ `Websocket`: Экземпляр вебсокета для управления соединением.
82
+ """
83
+ wrapper = self._make_wrapper(self._adapter.klines_message, callback)
84
+ return self._websocket_manager.futures_klines(
85
+ callback=wrapper,
86
+ symbol=symbol,
87
+ symbols=symbols,
88
+ interval=timeframe.to_exchange_format(Exchange.BINANCE, MarketType.FUTURES),
89
+ )
90
+
91
+ def trades(
92
+ self, callback: CallbackType, symbol: str | None = None, symbols: list[str] | None = None
93
+ ) -> Websocket:
94
+ """Создаёт вебсокет для получения сделок на споте с унификацией сообщений.
95
+
96
+ Параметры:
97
+ callback (`CallbackType`): Асинхронная функция обработки адаптированных сообщений.
98
+ symbol (`str | None`): Один символ для подписки.
99
+ symbols (`list[str] | None`): Список символов для мультиплекс‑подключения.
100
+
101
+ Должен быть указан либо `symbol`, либо `symbols`.
102
+
103
+ Возвращает:
104
+ `Websocket`: Экземпляр вебсокета для управления соединением.
105
+ """
106
+ wrapper = self._make_wrapper(self._adapter.trades_message, callback)
107
+ return self._websocket_manager.trade(callback=wrapper, symbol=symbol, symbols=symbols)
108
+
109
+ def aggtrades(
110
+ self, callback: CallbackType, symbol: str | None = None, symbols: list[str] | None = None
111
+ ) -> Websocket:
112
+ """Создаёт вебсокет для получения агрегированных сделок на споте с унификацией сообщений.
113
+
114
+ Параметры:
115
+ callback (`CallbackType`): Асинхронная функция обработки адаптированных сообщений.
116
+ symbol (`str | None`): Один символ для подписки.
117
+ symbols (`list[str] | None`): Список символов для мультиплекс‑подключения.
118
+
119
+ Должен быть указан либо `symbol`, либо `symbols`.
120
+
121
+ Возвращает:
122
+ `Websocket`: Экземпляр вебсокета для управления соединением.
123
+ """
124
+ wrapper = self._make_wrapper(self._adapter.aggtrades_message, callback)
125
+ return self._websocket_manager.agg_trade(callback=wrapper, symbol=symbol, symbols=symbols)
126
+
127
+ def futures_trades(
128
+ self, callback: CallbackType, symbol: str | None = None, symbols: list[str] | None = None
129
+ ) -> Websocket:
130
+ """Создаёт вебсокет для получения сделок на фьючерсах с унификацией сообщений.
131
+
132
+ Параметры:
133
+ callback (`CallbackType`): Асинхронная функция обработки
134
+ адаптированных сообщений.
135
+ symbol (`str | None`): Один символ для подписки.
136
+ symbols (`list[str] | None`): Список символов для мультиплекс‑подключения.
137
+
138
+ Должен быть указан либо `symbol`, либо `symbols`.
139
+
140
+ Возвращает:
141
+ `Websocket`: Экземпляр вебсокета для управления соединением.
142
+ """
143
+ wrapper = self._make_wrapper(self._adapter.trades_message, callback)
144
+ return self._websocket_manager.futures_trade(
145
+ callback=wrapper, symbol=symbol, symbols=symbols
146
+ )
147
+
148
+ def futures_aggtrades(
149
+ self, callback: CallbackType, symbol: str | None = None, symbols: list[str] | None = None
150
+ ) -> Websocket:
151
+ """Создаёт вебсокет для получения агрегированных сделок на фьючерсах с унификацией сообщений.
152
+
153
+ Параметры:
154
+ callback (`CallbackType`): Асинхронная функция обработки адаптированных сообщений.
155
+ symbol (`str | None`): Один символ для подписки.
156
+ symbols (`list[str] | None`): Список символов для мультиплекс‑подключения.
157
+
158
+ Должен быть указан либо `symbol`, либо `symbols`.
159
+
160
+ Возвращает:
161
+ `Websocket`: Экземпляр вебсокета для управления соединением.
162
+ """
163
+ wrapper = self._make_wrapper(self._adapter.aggtrades_message, callback)
164
+ return self._websocket_manager.futures_agg_trade(
165
+ callback=wrapper, symbol=symbol, symbols=symbols
166
+ )
@@ -0,0 +1,186 @@
1
+ __all__ = ["UserWebsocket"]
2
+
3
+ import asyncio
4
+ from collections.abc import Awaitable, Callable
5
+ from typing import Any, Literal
6
+
7
+ from loguru import logger as _logger
8
+
9
+ from unicex._base import Websocket
10
+ from unicex.exceptions import NotSupported
11
+ from unicex.types import LoggerLike
12
+
13
+ from .client import Client
14
+
15
+
16
+ class UserWebsocket:
17
+ """Пользовательский вебсокет Binance с авто‑продлением listenKey.
18
+
19
+ Поддержка типов аккаунта: "SPOT" и "FUTURES" (USDT‑M фьючерсы).
20
+ """
21
+
22
+ _BASE_SPOT_URL: str = "wss://stream.binance.com:9443"
23
+ """Базовый URL для вебсокета на спот."""
24
+
25
+ _BASE_FUTURES_URL: str = "wss://fstream.binance.com"
26
+ """Базовый URL для вебсокета на фьючерсы."""
27
+
28
+ _RENEW_INTERVAL: int = 30 * 60
29
+ """Интервал продления listenKey (сек.)"""
30
+
31
+ def __init__(
32
+ self,
33
+ callback: Callable[[Any], Awaitable[None]],
34
+ client: Client,
35
+ type: Literal["SPOT", "FUTURES"],
36
+ logger: LoggerLike | None = None,
37
+ **kwargs: Any, # Не дадим сломаться, если юзер передал ненужные аргументы
38
+ ) -> None:
39
+ """Инициализирует пользовательский вебсокет для работы с биржей Binance.
40
+
41
+ Параметры:
42
+ callback (`Callable`): Асинхронная функция обратного вызова, которая принимает сообщение с вебсокета.
43
+ client (`Client`): Авторизованный клиент Binance.
44
+ type (`str`): Тип аккаунта ("SPOT" | "FUTURES").
45
+ logger (`LoggerLike | None`): Логгер для записи логов.
46
+ """
47
+ self._callback = callback
48
+ self._client = client
49
+ self._type = type
50
+
51
+ self._listen_key: str | None = None
52
+ self._ws: Websocket | None = None
53
+ self._keepalive_task: asyncio.Task | None = None
54
+
55
+ self._logger = logger or _logger
56
+
57
+ self._running = False
58
+
59
+ @classmethod
60
+ def _create_ws_url(cls, type: Literal["SPOT", "FUTURES"], listen_key: str) -> str:
61
+ """Создает URL для подключения к WebSocket."""
62
+ if type == "FUTURES":
63
+ return f"{cls._BASE_FUTURES_URL}/ws/{listen_key}"
64
+ if type == "SPOT":
65
+ return f"{cls._BASE_SPOT_URL}/ws/{listen_key}"
66
+ raise NotSupported(f"Account type '{type}' not supported")
67
+
68
+ async def start(self) -> None:
69
+ """Запускает пользовательский стрим с автопродлением listenKey."""
70
+ self._running = True
71
+ self._listen_key = await self._create_listen_key()
72
+ await self._start_ws(self._create_ws_url(self._type, self._listen_key)) # type: ignore
73
+
74
+ # Фоновое продление ключа прослушивания
75
+ self._keepalive_task = asyncio.create_task(self._keepalive_loop())
76
+
77
+ async def stop(self) -> None:
78
+ """Останавливает стрим и закрывает listenKey."""
79
+ self._running = False
80
+
81
+ # Останавливаем вебсокет
82
+ try:
83
+ if isinstance(self._ws, Websocket):
84
+ await self._ws.stop()
85
+ except Exception as e:
86
+ self._logger.error(f"Error stopping WebSocket: {e}")
87
+
88
+ # Ожидаем завершения фонового продления ключа прослушивания
89
+ if isinstance(self._keepalive_task, asyncio.Task):
90
+ try:
91
+ self._keepalive_task.cancel()
92
+ await self._keepalive_task
93
+ except asyncio.CancelledError:
94
+ pass
95
+ except Exception as e:
96
+ self._logger.error(f"Error stopping keepalive task: {e}")
97
+
98
+ # Закрываем ключ прослушивания
99
+ try:
100
+ if self._listen_key:
101
+ await self._close_listen_key(self._listen_key)
102
+ except Exception as e:
103
+ self._logger.error(f"Error closing listenKey: {e}")
104
+ finally:
105
+ self._listen_key = None
106
+
107
+ self._logger.info("User websocket stopped")
108
+
109
+ async def restart(self) -> None:
110
+ """Перезапускает WebSocket для User Data Stream."""
111
+ await self.stop()
112
+ await self.start()
113
+
114
+ async def _start_ws(self, ws_url: str) -> None:
115
+ """Запускает WebSocket для User Data Stream."""
116
+ self._ws = Websocket(callback=self._callback, url=ws_url, no_message_reconnect_timeout=None)
117
+ await self._ws.start()
118
+ self._logger.info(f"User websocket started: ...{ws_url[-5:]}")
119
+
120
+ async def _keepalive_loop(self) -> None:
121
+ """Фоновый цикл продления listenKey и восстановления сессии при необходимости."""
122
+ while self._running:
123
+ try:
124
+ if self._type == "FUTURES":
125
+ response = await self._renew_listen_key()
126
+ listen_key = response.get("listenKey") if isinstance(response, dict) else None
127
+ if not listen_key:
128
+ raise RuntimeError(f"Can not renew listenKey: {response}")
129
+
130
+ if listen_key != self._listen_key:
131
+ self._logger.info(
132
+ f"Listen key changed: {self._listen_key} -> {listen_key}. Restarting websocket"
133
+ )
134
+ await self.restart()
135
+ return
136
+
137
+ elif self._type == "SPOT":
138
+ await self._renew_listen_key()
139
+
140
+ else:
141
+ raise NotSupported(f"Account type '{self._type}' not supported")
142
+
143
+ except Exception as e:
144
+ self._logger.error(f"Error while keeping alive: {e}")
145
+ await self.restart()
146
+ return
147
+
148
+ # Ждём до следующего продления
149
+ for _ in range(self._RENEW_INTERVAL):
150
+ if not self._running:
151
+ return
152
+ await asyncio.sleep(1)
153
+
154
+ async def _create_listen_key(self) -> str:
155
+ """Создает новый listenKey для User Data Stream в зависимости от типа аккаунта."""
156
+ if self._type == "FUTURES":
157
+ resp = await self._client.futures_listen_key()
158
+ elif self._type == "SPOT":
159
+ resp = await self._client.listen_key()
160
+ else:
161
+ raise NotSupported(f"Account type '{self._type}' not supported")
162
+
163
+ key = resp.get("listenKey") if isinstance(resp, dict) else None
164
+ if not key:
165
+ raise RuntimeError(f"Can not create listenKey: {resp}")
166
+ return key
167
+
168
+ async def _renew_listen_key(self) -> dict:
169
+ """Продлевает listenKey. Возвращает новый ключ, если сервер его выдал."""
170
+ if not isinstance(self._listen_key, str):
171
+ raise RuntimeError("listenKey is not a string")
172
+ if self._type == "FUTURES":
173
+ return await self._client.futures_renew_listen_key()
174
+ elif self._type == "SPOT":
175
+ return await self._client.renew_listen_key(self._listen_key)
176
+ else:
177
+ raise NotSupported(f"Account type '{self._type}' not supported")
178
+
179
+ async def _close_listen_key(self, listen_key: str) -> None:
180
+ """Закрывает listenKey."""
181
+ if self._type == "FUTURES":
182
+ await self._client.futures_close_listen_key()
183
+ elif self._type == "SPOT":
184
+ await self._client.close_listen_key(listen_key)
185
+ else:
186
+ raise NotSupported(f"Account type '{self._type}' not supported")