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.
- unicex/__init__.py +200 -0
- unicex/_abc/__init__.py +11 -0
- unicex/_abc/exchange_info.py +216 -0
- unicex/_abc/uni_client.py +329 -0
- unicex/_abc/uni_websocket_manager.py +294 -0
- unicex/_base/__init__.py +9 -0
- unicex/_base/client.py +214 -0
- unicex/_base/websocket.py +261 -0
- unicex/binance/__init__.py +27 -0
- unicex/binance/adapter.py +202 -0
- unicex/binance/client.py +1577 -0
- unicex/binance/exchange_info.py +62 -0
- unicex/binance/uni_client.py +188 -0
- unicex/binance/uni_websocket_manager.py +166 -0
- unicex/binance/user_websocket.py +186 -0
- unicex/binance/websocket_manager.py +912 -0
- unicex/bitget/__init__.py +27 -0
- unicex/bitget/adapter.py +188 -0
- unicex/bitget/client.py +2514 -0
- unicex/bitget/exchange_info.py +48 -0
- unicex/bitget/uni_client.py +198 -0
- unicex/bitget/uni_websocket_manager.py +275 -0
- unicex/bitget/user_websocket.py +7 -0
- unicex/bitget/websocket_manager.py +232 -0
- unicex/bybit/__init__.py +27 -0
- unicex/bybit/adapter.py +208 -0
- unicex/bybit/client.py +1876 -0
- unicex/bybit/exchange_info.py +53 -0
- unicex/bybit/uni_client.py +200 -0
- unicex/bybit/uni_websocket_manager.py +291 -0
- unicex/bybit/user_websocket.py +7 -0
- unicex/bybit/websocket_manager.py +339 -0
- unicex/enums.py +273 -0
- unicex/exceptions.py +64 -0
- unicex/extra.py +335 -0
- unicex/gate/__init__.py +27 -0
- unicex/gate/adapter.py +178 -0
- unicex/gate/client.py +1667 -0
- unicex/gate/exchange_info.py +55 -0
- unicex/gate/uni_client.py +214 -0
- unicex/gate/uni_websocket_manager.py +269 -0
- unicex/gate/user_websocket.py +7 -0
- unicex/gate/websocket_manager.py +513 -0
- unicex/hyperliquid/__init__.py +27 -0
- unicex/hyperliquid/adapter.py +261 -0
- unicex/hyperliquid/client.py +2315 -0
- unicex/hyperliquid/exchange_info.py +119 -0
- unicex/hyperliquid/uni_client.py +325 -0
- unicex/hyperliquid/uni_websocket_manager.py +269 -0
- unicex/hyperliquid/user_websocket.py +7 -0
- unicex/hyperliquid/websocket_manager.py +393 -0
- unicex/mapper.py +111 -0
- unicex/mexc/__init__.py +27 -0
- unicex/mexc/_spot_ws_proto/PrivateAccountV3Api_pb2.py +38 -0
- unicex/mexc/_spot_ws_proto/PrivateDealsV3Api_pb2.py +38 -0
- unicex/mexc/_spot_ws_proto/PrivateOrdersV3Api_pb2.py +38 -0
- unicex/mexc/_spot_ws_proto/PublicAggreBookTickerV3Api_pb2.py +38 -0
- unicex/mexc/_spot_ws_proto/PublicAggreDealsV3Api_pb2.py +40 -0
- unicex/mexc/_spot_ws_proto/PublicAggreDepthsV3Api_pb2.py +40 -0
- unicex/mexc/_spot_ws_proto/PublicBookTickerBatchV3Api_pb2.py +38 -0
- unicex/mexc/_spot_ws_proto/PublicBookTickerV3Api_pb2.py +38 -0
- unicex/mexc/_spot_ws_proto/PublicDealsV3Api_pb2.py +40 -0
- unicex/mexc/_spot_ws_proto/PublicFuture_pb2.py +103 -0
- unicex/mexc/_spot_ws_proto/PublicIncreaseDepthsBatchV3Api_pb2.py +38 -0
- unicex/mexc/_spot_ws_proto/PublicIncreaseDepthsV3Api_pb2.py +40 -0
- unicex/mexc/_spot_ws_proto/PublicLimitDepthsV3Api_pb2.py +40 -0
- unicex/mexc/_spot_ws_proto/PublicMiniTickerV3Api_pb2.py +38 -0
- unicex/mexc/_spot_ws_proto/PublicMiniTickersV3Api_pb2.py +38 -0
- unicex/mexc/_spot_ws_proto/PublicSpotKlineV3Api_pb2.py +38 -0
- unicex/mexc/_spot_ws_proto/PushDataV3ApiWrapper_pb2.py +38 -0
- unicex/mexc/_spot_ws_proto/__init__.py +335 -0
- unicex/mexc/adapter.py +239 -0
- unicex/mexc/client.py +846 -0
- unicex/mexc/exchange_info.py +47 -0
- unicex/mexc/uni_client.py +211 -0
- unicex/mexc/uni_websocket_manager.py +269 -0
- unicex/mexc/user_websocket.py +7 -0
- unicex/mexc/websocket_manager.py +456 -0
- unicex/okx/__init__.py +27 -0
- unicex/okx/adapter.py +150 -0
- unicex/okx/client.py +2864 -0
- unicex/okx/exchange_info.py +47 -0
- unicex/okx/uni_client.py +202 -0
- unicex/okx/uni_websocket_manager.py +269 -0
- unicex/okx/user_websocket.py +7 -0
- unicex/okx/websocket_manager.py +743 -0
- unicex/types.py +164 -0
- unicex/utils.py +218 -0
- unicex-0.13.17.dist-info/METADATA +243 -0
- unicex-0.13.17.dist-info/RECORD +93 -0
- unicex-0.13.17.dist-info/WHEEL +5 -0
- unicex-0.13.17.dist-info/licenses/LICENSE +28 -0
- unicex-0.13.17.dist-info/top_level.txt +1 -0
unicex/_base/client.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
__all__ = ["BaseClient"]
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
from itertools import cycle
|
|
6
|
+
from typing import Any, Self
|
|
7
|
+
|
|
8
|
+
import aiohttp
|
|
9
|
+
from loguru import logger as _logger
|
|
10
|
+
|
|
11
|
+
from unicex.exceptions import ResponseError
|
|
12
|
+
from unicex.types import LoggerLike, RequestMethod
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BaseClient:
|
|
16
|
+
"""Базовый асинхронный класс для работы с API."""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
session: aiohttp.ClientSession,
|
|
21
|
+
api_key: str | None = None,
|
|
22
|
+
api_secret: str | None = None,
|
|
23
|
+
api_passphrase: str | None = None,
|
|
24
|
+
logger: LoggerLike | None = None,
|
|
25
|
+
max_retries: int = 3,
|
|
26
|
+
retry_delay: int | float = 0.1,
|
|
27
|
+
proxies: list[str] | None = None,
|
|
28
|
+
timeout: int = 10,
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Инициализация клиента.
|
|
31
|
+
|
|
32
|
+
Параметры:
|
|
33
|
+
session (`aiohttp.ClientSession`): Сессия для выполнения HTTP‑запросов.
|
|
34
|
+
api_key (`str | None`): Ключ API для аутентификации.
|
|
35
|
+
api_secret (`str | None`): Секретный ключ API для аутентификации.
|
|
36
|
+
api_passphrase (`str | None`): Пароль API для аутентификации (Bitget, OKX).
|
|
37
|
+
logger (`LoggerLike | None`): Логгер для вывода информации.
|
|
38
|
+
max_retries (`int`): Максимальное количество повторных попыток запроса.
|
|
39
|
+
retry_delay (`int | float`): Задержка между повторными попытками, сек.
|
|
40
|
+
proxies (`list[str] | None`): Список HTTP(S)‑прокси для циклического использования.
|
|
41
|
+
timeout (`int`): Максимальное время ожидания ответа от сервера, сек.
|
|
42
|
+
"""
|
|
43
|
+
self._api_key = api_key
|
|
44
|
+
self._api_secret = api_secret
|
|
45
|
+
self._api_passphrase = api_passphrase
|
|
46
|
+
self._session = session
|
|
47
|
+
self._logger = logger or _logger
|
|
48
|
+
self._max_retries = max(max_retries, 1)
|
|
49
|
+
self._retry_delay = max(retry_delay, 0)
|
|
50
|
+
self._proxies_cycle = cycle(proxies) if proxies else None
|
|
51
|
+
self._timeout = timeout
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
async def create(
|
|
55
|
+
cls,
|
|
56
|
+
api_key: str | None = None,
|
|
57
|
+
api_secret: str | None = None,
|
|
58
|
+
api_passphrase: str | None = None,
|
|
59
|
+
session: aiohttp.ClientSession | None = None,
|
|
60
|
+
logger: LoggerLike | None = None,
|
|
61
|
+
max_retries: int = 3,
|
|
62
|
+
retry_delay: int | float = 0.1,
|
|
63
|
+
proxies: list[str] | None = None,
|
|
64
|
+
timeout: int = 10,
|
|
65
|
+
) -> Self:
|
|
66
|
+
"""Создаёт инстанцию клиента.
|
|
67
|
+
|
|
68
|
+
Параметры:
|
|
69
|
+
api_key (`str | None`): Ключ API для аутентификации.
|
|
70
|
+
api_secret (`str | None`): Секретный ключ API для аутентификации.
|
|
71
|
+
api_passphrase (`str | None`): Пароль API для аутентификации (Bitget, OKX).
|
|
72
|
+
session (`aiohttp.ClientSession | None`): Сессия для HTTP‑запросов (если не передана, будет создана).
|
|
73
|
+
logger (`LoggerLike | None`): Логгер для вывода информации.
|
|
74
|
+
max_retries (`int`): Максимум повторов при ошибках запроса.
|
|
75
|
+
retry_delay (`int | float`): Задержка между повторами, сек.
|
|
76
|
+
proxies (`list[str] | None`): Список HTTP(S)‑прокси.
|
|
77
|
+
timeout (`int`): Таймаут ответа сервера, сек.
|
|
78
|
+
|
|
79
|
+
Возвращает:
|
|
80
|
+
`Self`: Созданный экземпляр клиента.
|
|
81
|
+
"""
|
|
82
|
+
return cls(
|
|
83
|
+
session=session or aiohttp.ClientSession(),
|
|
84
|
+
api_key=api_key,
|
|
85
|
+
api_secret=api_secret,
|
|
86
|
+
api_passphrase=api_passphrase,
|
|
87
|
+
logger=logger,
|
|
88
|
+
max_retries=max_retries,
|
|
89
|
+
retry_delay=retry_delay,
|
|
90
|
+
proxies=proxies,
|
|
91
|
+
timeout=timeout,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
async def close_connection(self) -> None:
|
|
95
|
+
"""Закрывает сессию."""
|
|
96
|
+
await self._session.close()
|
|
97
|
+
|
|
98
|
+
def is_authorized(self) -> bool:
|
|
99
|
+
"""Проверяет наличие API‑ключей у клиента.
|
|
100
|
+
|
|
101
|
+
Возвращает:
|
|
102
|
+
`bool`: Признак наличия ключей.
|
|
103
|
+
"""
|
|
104
|
+
return self._api_key is not None and self._api_secret is not None
|
|
105
|
+
|
|
106
|
+
async def __aenter__(self) -> Self:
|
|
107
|
+
"""Вход в асинхронный контекст."""
|
|
108
|
+
return self
|
|
109
|
+
|
|
110
|
+
async def __aexit__(self, *_) -> None:
|
|
111
|
+
"""Выход из асинхронного контекста."""
|
|
112
|
+
await self.close_connection()
|
|
113
|
+
|
|
114
|
+
async def _make_request(
|
|
115
|
+
self,
|
|
116
|
+
method: RequestMethod,
|
|
117
|
+
url: str,
|
|
118
|
+
params: dict[str, Any] | None = None,
|
|
119
|
+
data: dict[str, Any] | None = None,
|
|
120
|
+
headers: dict[str, Any] | None = None,
|
|
121
|
+
) -> Any:
|
|
122
|
+
"""Выполняет HTTP‑запрос к API биржи.
|
|
123
|
+
|
|
124
|
+
Параметры:
|
|
125
|
+
method (`RequestMethod`): HTTP‑метод запроса.
|
|
126
|
+
url (`str`): Полный URL API.
|
|
127
|
+
params (`dict[str, Any] | None`): Параметры запроса (query string).
|
|
128
|
+
data (`dict[str, Any] | None`): Тело запроса для POST/PUT.
|
|
129
|
+
headers (`dict[str, Any] | None`): Заголовки запроса.
|
|
130
|
+
|
|
131
|
+
Возвращает:
|
|
132
|
+
`dict | list`: Ответ API в формате JSON.
|
|
133
|
+
"""
|
|
134
|
+
self._logger.debug(
|
|
135
|
+
f"Request: {method} {url} | Params: {params} | Data: {data} | Headers: {headers}"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
errors = []
|
|
139
|
+
for attempt in range(1, self._max_retries + 1):
|
|
140
|
+
try:
|
|
141
|
+
async with self._session.request(
|
|
142
|
+
method=method,
|
|
143
|
+
url=url,
|
|
144
|
+
params=params,
|
|
145
|
+
json=data if method in {"POST", "PUT"} else None, # Передача тела запроса
|
|
146
|
+
headers=headers,
|
|
147
|
+
proxy=next(self._proxies_cycle) if self._proxies_cycle else None,
|
|
148
|
+
timeout=aiohttp.ClientTimeout(total=self._timeout) if self._timeout else None,
|
|
149
|
+
) as response:
|
|
150
|
+
return await self._handle_response(response=response)
|
|
151
|
+
|
|
152
|
+
except (aiohttp.ServerTimeoutError, aiohttp.ConnectionTimeoutError) as e:
|
|
153
|
+
errors.append(e)
|
|
154
|
+
self._logger.debug(
|
|
155
|
+
f"Attempt {attempt}/{self._max_retries} failed: {type(e)} -> {e}"
|
|
156
|
+
)
|
|
157
|
+
if attempt < self._max_retries:
|
|
158
|
+
await asyncio.sleep(self._retry_delay)
|
|
159
|
+
|
|
160
|
+
raise ConnectionError(
|
|
161
|
+
f"Connection error after {self._max_retries} request on {method} {url}. Errors: {errors}"
|
|
162
|
+
) from errors[-1]
|
|
163
|
+
|
|
164
|
+
async def _handle_response(self, response: aiohttp.ClientResponse) -> Any:
|
|
165
|
+
"""Обрабатывает HTTP‑ответ.
|
|
166
|
+
|
|
167
|
+
Параметры:
|
|
168
|
+
response (`aiohttp.ClientResponse`): Ответ HTTP‑запроса.
|
|
169
|
+
|
|
170
|
+
Возвращает:
|
|
171
|
+
`dict | list`: Ответ API в формате JSON.
|
|
172
|
+
"""
|
|
173
|
+
response_text = await response.text()
|
|
174
|
+
status_code = response.status
|
|
175
|
+
|
|
176
|
+
# Парсинг JSON
|
|
177
|
+
try:
|
|
178
|
+
response_json = json.loads(response_text)
|
|
179
|
+
except json.JSONDecodeError as e:
|
|
180
|
+
raise ResponseError(
|
|
181
|
+
f"JSONDecodeError: {e}. Response: {response_text}. Status code: {response.status}",
|
|
182
|
+
status_code=status_code,
|
|
183
|
+
response_text=response_text,
|
|
184
|
+
) from None
|
|
185
|
+
|
|
186
|
+
# Проверка HTTP-статуса
|
|
187
|
+
try:
|
|
188
|
+
response.raise_for_status()
|
|
189
|
+
except Exception as e:
|
|
190
|
+
error_code = next(
|
|
191
|
+
(
|
|
192
|
+
response_json[k]
|
|
193
|
+
for k in ("code", "err_code", "errCode", "status")
|
|
194
|
+
if k in response_json
|
|
195
|
+
),
|
|
196
|
+
"",
|
|
197
|
+
)
|
|
198
|
+
raise ResponseError(
|
|
199
|
+
f"HTTP error: {e}. Response: {response_json}. Status code: {response.status}",
|
|
200
|
+
status_code=status_code,
|
|
201
|
+
code=error_code,
|
|
202
|
+
response_text=response_text,
|
|
203
|
+
response_json=response_json,
|
|
204
|
+
) from None
|
|
205
|
+
|
|
206
|
+
# Логирование ответа
|
|
207
|
+
try:
|
|
208
|
+
self._logger.debug(
|
|
209
|
+
f"Response: {response_text[:300]}{'...' if len(response_text) > 300 else ''}"
|
|
210
|
+
)
|
|
211
|
+
except Exception as e:
|
|
212
|
+
self._logger.error(f"Error while logging response: {e}")
|
|
213
|
+
|
|
214
|
+
return response_json
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
__all__ = ["Websocket"]
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
6
|
+
from typing import Any, Protocol
|
|
7
|
+
|
|
8
|
+
import orjson
|
|
9
|
+
import websockets
|
|
10
|
+
from loguru import logger as _logger
|
|
11
|
+
from websockets.asyncio.client import ClientConnection
|
|
12
|
+
|
|
13
|
+
from unicex.exceptions import QueueOverflowError
|
|
14
|
+
from unicex.types import LoggerLike
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Websocket:
|
|
18
|
+
"""Базовый класс асинхронного вебсокета."""
|
|
19
|
+
|
|
20
|
+
MAX_QUEUE_SIZE: int = 500
|
|
21
|
+
"""Максимальная длина очереди."""
|
|
22
|
+
|
|
23
|
+
class _DecoderProtocol(Protocol):
|
|
24
|
+
"""Протокол декодирования сообщений."""
|
|
25
|
+
|
|
26
|
+
def decode(self, message: Any) -> dict: ...
|
|
27
|
+
|
|
28
|
+
class _JsonDecoder:
|
|
29
|
+
"""Протокол декодирования сообщений в формате JSON."""
|
|
30
|
+
|
|
31
|
+
def decode(self, message: Any) -> dict:
|
|
32
|
+
return orjson.loads(message)
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
callback: Callable[[Any], Awaitable[None]],
|
|
37
|
+
url: str,
|
|
38
|
+
subscription_messages: list[dict] | list[str] | None = None,
|
|
39
|
+
ping_interval: int | float = 10,
|
|
40
|
+
ping_message: str | Callable | None = None,
|
|
41
|
+
pong_message: str | Callable | None = None,
|
|
42
|
+
no_message_reconnect_timeout: int | float | None = 60,
|
|
43
|
+
reconnect_timeout: int | float | None = 5,
|
|
44
|
+
worker_count: int = 1,
|
|
45
|
+
logger: LoggerLike | None = None,
|
|
46
|
+
decoder: type[_DecoderProtocol] = _JsonDecoder,
|
|
47
|
+
**kwargs: Any, # Не дадим сломаться, если юзер передал ненужные аргументы
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Инициализация вебсокета.
|
|
50
|
+
|
|
51
|
+
Параметры:
|
|
52
|
+
callback (`Callable[[Any], Awaitable[None]]`): Обработчик входящих сообщений.
|
|
53
|
+
url (`str`): URL вебсокета.
|
|
54
|
+
subscription_messages (`list[dict] | list[str] | None`): Сообщения для подписки после подключения.
|
|
55
|
+
ping_interval (`int | float`): Интервал отправки ping, сек.
|
|
56
|
+
ping_message (`str | Callable | None`): Сообщение для ping, или функция генерации ping (если не указано — используется ping‑frame).
|
|
57
|
+
pong_message (`str | Callable | None`): Сообщение для pong, или функция генерации pong (если не указано — используется pong‑frame).
|
|
58
|
+
no_message_reconnect_timeout (`int | float | None`): Таймаут ожидания без сообщений до рестарта, сек.
|
|
59
|
+
reconnect_timeout (`int | float | None`): Пауза перед переподключением, сек.
|
|
60
|
+
worker_count (`int`): Количество рабочих задач для обработки сообщений.
|
|
61
|
+
logger (`LoggerLike | None`): Логгер для записи логов.
|
|
62
|
+
decoder (`IDecoder | None`): Декодер для обработки входящих сообщений.
|
|
63
|
+
"""
|
|
64
|
+
self._callback = callback
|
|
65
|
+
self._url = url
|
|
66
|
+
self._subscription_messages = subscription_messages or []
|
|
67
|
+
self._ping_interval = ping_interval
|
|
68
|
+
self._ping_message = ping_message
|
|
69
|
+
self._pong_message = pong_message
|
|
70
|
+
self._no_message_reconnect_timeout = no_message_reconnect_timeout
|
|
71
|
+
self._reconnect_timeout = reconnect_timeout or 0
|
|
72
|
+
self._last_message_time = time.monotonic()
|
|
73
|
+
self._worker_count = worker_count
|
|
74
|
+
self._logger = logger or _logger
|
|
75
|
+
self._decoder = decoder()
|
|
76
|
+
self._tasks: list[asyncio.Task] = []
|
|
77
|
+
self._queue = asyncio.Queue()
|
|
78
|
+
self._running = False
|
|
79
|
+
|
|
80
|
+
async def start(self) -> None:
|
|
81
|
+
"""Запускает вебсокет и рабочие задачи."""
|
|
82
|
+
# Проверяем что вебсокет еще не запущен
|
|
83
|
+
if self._running:
|
|
84
|
+
raise RuntimeError("Websocket is already running")
|
|
85
|
+
self._running = True
|
|
86
|
+
|
|
87
|
+
# Запускаем вебсокет
|
|
88
|
+
try:
|
|
89
|
+
await self._connect()
|
|
90
|
+
except Exception as e:
|
|
91
|
+
self._logger.error(f"Failed to connect to websocket: {e}")
|
|
92
|
+
self._running = False
|
|
93
|
+
raise
|
|
94
|
+
|
|
95
|
+
async def stop(self) -> None:
|
|
96
|
+
"""Останавливает вебсокет и рабочие задачи."""
|
|
97
|
+
self._running = False
|
|
98
|
+
await self._after_disconnect()
|
|
99
|
+
|
|
100
|
+
async def restart(self) -> None:
|
|
101
|
+
"""Перезапускает вебсокет."""
|
|
102
|
+
await self.stop()
|
|
103
|
+
await asyncio.sleep(self._reconnect_timeout)
|
|
104
|
+
await self.start()
|
|
105
|
+
|
|
106
|
+
async def _connect(self) -> None:
|
|
107
|
+
"""Подключается к вебсокету и настраивает соединение."""
|
|
108
|
+
self._logger.debug(f"Establishing connection with {self._url}")
|
|
109
|
+
async for conn in websockets.connect(uri=self._url, **self._generate_ws_kwargs()):
|
|
110
|
+
try:
|
|
111
|
+
self._logger.info(f"Websocket connection was established to {self._url}")
|
|
112
|
+
await self._after_connect(conn)
|
|
113
|
+
|
|
114
|
+
# Цикл получения сообщений
|
|
115
|
+
while self._running:
|
|
116
|
+
message = await conn.recv()
|
|
117
|
+
await self._handle_message(message)
|
|
118
|
+
|
|
119
|
+
except websockets.exceptions.ConnectionClosed as e:
|
|
120
|
+
self._logger.error(f"Websocket connection was closed unexpectedly: {e}")
|
|
121
|
+
except Exception as e:
|
|
122
|
+
self._logger.error(f"Unexpected error in websosocket connection: {e}")
|
|
123
|
+
finally:
|
|
124
|
+
# Делаем реконнект только если вебсокет активен, иначе выходим из итератора
|
|
125
|
+
if self._running:
|
|
126
|
+
await asyncio.sleep(self._reconnect_timeout)
|
|
127
|
+
await self._after_disconnect()
|
|
128
|
+
else:
|
|
129
|
+
return # Выходим из итератора, если вебсокет уже выключен
|
|
130
|
+
|
|
131
|
+
async def _handle_message(self, message: str | bytes) -> None:
|
|
132
|
+
"""Обрабатывает входящее сообщение вебсокета."""
|
|
133
|
+
try:
|
|
134
|
+
# Обновленяем время последнего сообщения
|
|
135
|
+
self._last_message_time = time.monotonic()
|
|
136
|
+
|
|
137
|
+
# Ложим сообщение в очередь, предварительно его сериализуя
|
|
138
|
+
decoded_message = self._decoder.decode(message)
|
|
139
|
+
await self._queue.put(decoded_message)
|
|
140
|
+
|
|
141
|
+
# Проверяем размер очереди сообщений и выбрасываем ошибку, если он превышает максимальный размер
|
|
142
|
+
self._check_queue_size()
|
|
143
|
+
except QueueOverflowError:
|
|
144
|
+
self._logger.error("Message queue is overflow")
|
|
145
|
+
except orjson.JSONDecodeError as e:
|
|
146
|
+
if message in ["ping", "pong"]:
|
|
147
|
+
self._logger.debug(f"Received ping message: {message}")
|
|
148
|
+
else:
|
|
149
|
+
self._logger.error(f"Failed to decode JSON message: {message}, error: {e}")
|
|
150
|
+
except Exception as e:
|
|
151
|
+
self._logger.error(f"Unexpected error: {e}")
|
|
152
|
+
|
|
153
|
+
def _check_queue_size(self) -> None:
|
|
154
|
+
"""Проверяет размер очереди и выбрасывает ошибку при переполнении."""
|
|
155
|
+
qsize = self._queue.qsize()
|
|
156
|
+
if qsize >= self.MAX_QUEUE_SIZE:
|
|
157
|
+
raise QueueOverflowError(f"Message queue is overflow: {qsize}")
|
|
158
|
+
|
|
159
|
+
async def _after_connect(self, conn: ClientConnection) -> None:
|
|
160
|
+
"""Вызывается после установки соединения."""
|
|
161
|
+
# Подписываемся на топики
|
|
162
|
+
await self._send_subscribe_messages(conn)
|
|
163
|
+
|
|
164
|
+
# Обновленяем время последнего сообщения перед каждым подключением
|
|
165
|
+
self._last_message_time = time.monotonic()
|
|
166
|
+
|
|
167
|
+
# Запускам задачу для кастомного пинг сообщения
|
|
168
|
+
if self._ping_message:
|
|
169
|
+
self._tasks.append(asyncio.create_task(self._custom_ping_task(conn)))
|
|
170
|
+
|
|
171
|
+
# Запускаем healthcheck
|
|
172
|
+
if self._no_message_reconnect_timeout:
|
|
173
|
+
self._tasks.append(asyncio.create_task(self._healthcheck_task()))
|
|
174
|
+
|
|
175
|
+
# Запускаем воркеров
|
|
176
|
+
for _ in range(self._worker_count):
|
|
177
|
+
task = asyncio.create_task(self._worker())
|
|
178
|
+
self._tasks.append(task)
|
|
179
|
+
|
|
180
|
+
async def _after_disconnect(self) -> None:
|
|
181
|
+
"""Вызывается после отключения от вебсокета."""
|
|
182
|
+
current_task = asyncio.current_task()
|
|
183
|
+
|
|
184
|
+
# Останавливаем воркеров, исключая задачу, которая уже выполняет остановку
|
|
185
|
+
tasks_to_wait: list[asyncio.Task] = []
|
|
186
|
+
for task in self._tasks:
|
|
187
|
+
if task is current_task:
|
|
188
|
+
continue
|
|
189
|
+
|
|
190
|
+
task.cancel()
|
|
191
|
+
tasks_to_wait.append(task)
|
|
192
|
+
|
|
193
|
+
# Дожидаемся завершения задач (в т.ч. воркеров)
|
|
194
|
+
if tasks_to_wait:
|
|
195
|
+
results = await asyncio.gather(*tasks_to_wait, return_exceptions=True)
|
|
196
|
+
for task_result in results:
|
|
197
|
+
if isinstance(task_result, asyncio.CancelledError):
|
|
198
|
+
continue
|
|
199
|
+
if isinstance(task_result, Exception):
|
|
200
|
+
self._logger.warning(f"Worker raised during shutdown: {task_result}")
|
|
201
|
+
|
|
202
|
+
self._tasks.clear()
|
|
203
|
+
|
|
204
|
+
# Очистить очередь уже безопасно, после остановки воркеров
|
|
205
|
+
self._queue = asyncio.Queue()
|
|
206
|
+
|
|
207
|
+
async def _send_subscribe_messages(self, conn: ClientConnection) -> None:
|
|
208
|
+
"""Отправляет сообщения с подпиской на топики, если нужно."""
|
|
209
|
+
for message in self._subscription_messages:
|
|
210
|
+
await conn.send(message)
|
|
211
|
+
self._logger.debug(f"Sent subscribe message: {message}")
|
|
212
|
+
|
|
213
|
+
async def _worker(self) -> None:
|
|
214
|
+
"""Обрабатывает сообщения из очереди."""
|
|
215
|
+
while self._running:
|
|
216
|
+
try:
|
|
217
|
+
data = await self._queue.get() # Получаем сообщение
|
|
218
|
+
await self._callback(data) # Передаем в callback
|
|
219
|
+
self._queue.task_done()
|
|
220
|
+
except asyncio.exceptions.CancelledError:
|
|
221
|
+
break
|
|
222
|
+
except Exception as e:
|
|
223
|
+
self._logger.error(f"Error({type(e)}) while processing message: {e}")
|
|
224
|
+
|
|
225
|
+
def _generate_ws_kwargs(self) -> dict:
|
|
226
|
+
"""Генерирует аргументы для запуска вебсокета."""
|
|
227
|
+
ws_kwargs = {}
|
|
228
|
+
if self._ping_interval:
|
|
229
|
+
ws_kwargs["ping_interval"] = self._ping_interval
|
|
230
|
+
return ws_kwargs
|
|
231
|
+
|
|
232
|
+
async def _custom_ping_task(self, conn: ClientConnection) -> None:
|
|
233
|
+
"""Периодически отправляет пользовательский ping."""
|
|
234
|
+
while self._running and self._ping_message:
|
|
235
|
+
try:
|
|
236
|
+
if isinstance(self._ping_message, Callable):
|
|
237
|
+
ping_message = self._ping_message()
|
|
238
|
+
else:
|
|
239
|
+
ping_message = self._ping_message
|
|
240
|
+
await conn.send(ping_message)
|
|
241
|
+
self._logger.debug(f"Sent ping message: {ping_message}")
|
|
242
|
+
except Exception as e:
|
|
243
|
+
self._logger.error(f"Error sending ping: {e}")
|
|
244
|
+
return
|
|
245
|
+
await asyncio.sleep(self._ping_interval)
|
|
246
|
+
|
|
247
|
+
async def _healthcheck_task(self) -> None:
|
|
248
|
+
"""Следит за таймаутом получения сообщений."""
|
|
249
|
+
if not self._no_message_reconnect_timeout:
|
|
250
|
+
return
|
|
251
|
+
|
|
252
|
+
while self._running:
|
|
253
|
+
if time.monotonic() - self._last_message_time > self._no_message_reconnect_timeout:
|
|
254
|
+
self._logger.error("Websocket is not responding, restarting...")
|
|
255
|
+
await self.restart()
|
|
256
|
+
return
|
|
257
|
+
await asyncio.sleep(1)
|
|
258
|
+
|
|
259
|
+
def __repr__(self) -> str:
|
|
260
|
+
"""Репрезентация вебсокета."""
|
|
261
|
+
return f"<Websocket(url={self._url[:15]}...)>"
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Пакет, содержащий реализации клиентов и менеджеров для работы с биржей Binance."""
|
|
2
|
+
|
|
3
|
+
__all__ = [
|
|
4
|
+
"Client",
|
|
5
|
+
"UniClient",
|
|
6
|
+
"UserWebsocket",
|
|
7
|
+
"WebsocketManager",
|
|
8
|
+
"UniWebsocketManager",
|
|
9
|
+
"ExchangeInfo",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
from .client import Client
|
|
13
|
+
from .exchange_info import ExchangeInfo
|
|
14
|
+
from .uni_client import UniClient
|
|
15
|
+
from .uni_websocket_manager import UniWebsocketManager
|
|
16
|
+
from .user_websocket import UserWebsocket
|
|
17
|
+
from .websocket_manager import WebsocketManager
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def load_exchange_info() -> None:
|
|
21
|
+
"""Загружает информацию о бирже Binance."""
|
|
22
|
+
await ExchangeInfo.load_exchange_info()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def start_exchange_info(parse_interval_seconds: int = 60 * 60) -> None:
|
|
26
|
+
"""Запускает процесс обновления информации о бирже Binance."""
|
|
27
|
+
await ExchangeInfo.start(parse_interval_seconds)
|