unicex 0.8.0__tar.gz → 0.8.1__tar.gz

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 (101) hide show
  1. {unicex-0.8.0/unicex.egg-info → unicex-0.8.1}/PKG-INFO +3 -2
  2. {unicex-0.8.0 → unicex-0.8.1}/README.md +1 -1
  3. {unicex-0.8.0 → unicex-0.8.1}/pyproject.toml +2 -1
  4. {unicex-0.8.0 → unicex-0.8.1}/unicex/_abc/__init__.py +4 -0
  5. unicex-0.8.1/unicex/_abc/decoder.py +18 -0
  6. {unicex-0.8.0 → unicex-0.8.1}/unicex/_abc/uni_client.py +1 -1
  7. unicex-0.8.1/unicex/_base/__init__.py +9 -0
  8. {unicex-0.8.0 → unicex-0.8.1}/unicex/_base/websocket.py +16 -4
  9. {unicex-0.8.0 → unicex-0.8.1}/unicex/binance/client.py +2 -2
  10. {unicex-0.8.0 → unicex-0.8.1}/unicex/bitget/client.py +25 -10
  11. {unicex-0.8.0 → unicex-0.8.1}/unicex/bybit/client.py +4 -4
  12. unicex-0.8.1/unicex/bybit/websocket_manager.py +339 -0
  13. {unicex-0.8.0 → unicex-0.8.1}/unicex/gateio/client.py +1 -1
  14. unicex-0.8.1/unicex/mexc/_spot_ws_proto/PrivateAccountV3Api_pb2.py +40 -0
  15. unicex-0.8.1/unicex/mexc/_spot_ws_proto/PrivateDealsV3Api_pb2.py +40 -0
  16. unicex-0.8.1/unicex/mexc/_spot_ws_proto/PrivateOrdersV3Api_pb2.py +40 -0
  17. unicex-0.8.1/unicex/mexc/_spot_ws_proto/PublicAggreBookTickerV3Api_pb2.py +40 -0
  18. unicex-0.8.1/unicex/mexc/_spot_ws_proto/PublicAggreDealsV3Api_pb2.py +42 -0
  19. unicex-0.8.1/unicex/mexc/_spot_ws_proto/PublicAggreDepthsV3Api_pb2.py +42 -0
  20. unicex-0.8.1/unicex/mexc/_spot_ws_proto/PublicBookTickerBatchV3Api_pb2.py +40 -0
  21. unicex-0.8.1/unicex/mexc/_spot_ws_proto/PublicBookTickerV3Api_pb2.py +40 -0
  22. unicex-0.8.1/unicex/mexc/_spot_ws_proto/PublicDealsV3Api_pb2.py +42 -0
  23. unicex-0.8.1/unicex/mexc/_spot_ws_proto/PublicFuture_pb2.py +105 -0
  24. unicex-0.8.1/unicex/mexc/_spot_ws_proto/PublicIncreaseDepthsBatchV3Api_pb2.py +40 -0
  25. unicex-0.8.1/unicex/mexc/_spot_ws_proto/PublicIncreaseDepthsV3Api_pb2.py +42 -0
  26. unicex-0.8.1/unicex/mexc/_spot_ws_proto/PublicLimitDepthsV3Api_pb2.py +42 -0
  27. unicex-0.8.1/unicex/mexc/_spot_ws_proto/PublicMiniTickerV3Api_pb2.py +40 -0
  28. unicex-0.8.1/unicex/mexc/_spot_ws_proto/PublicMiniTickersV3Api_pb2.py +40 -0
  29. unicex-0.8.1/unicex/mexc/_spot_ws_proto/PublicSpotKlineV3Api_pb2.py +40 -0
  30. unicex-0.8.1/unicex/mexc/_spot_ws_proto/PushDataV3ApiWrapper_pb2.py +40 -0
  31. unicex-0.8.1/unicex/mexc/_spot_ws_proto/__init__.py +332 -0
  32. {unicex-0.8.0 → unicex-0.8.1}/unicex/mexc/client.py +2 -2
  33. {unicex-0.8.0 → unicex-0.8.1}/unicex/okx/client.py +26 -15
  34. {unicex-0.8.0 → unicex-0.8.1/unicex.egg-info}/PKG-INFO +3 -2
  35. {unicex-0.8.0 → unicex-0.8.1}/unicex.egg-info/SOURCES.txt +19 -0
  36. {unicex-0.8.0 → unicex-0.8.1}/unicex.egg-info/requires.txt +1 -0
  37. unicex-0.8.0/unicex/_base/__init__.py +0 -7
  38. unicex-0.8.0/unicex/bybit/websocket_manager.py +0 -11
  39. {unicex-0.8.0 → unicex-0.8.1}/LICENSE +0 -0
  40. {unicex-0.8.0 → unicex-0.8.1}/setup.cfg +0 -0
  41. {unicex-0.8.0 → unicex-0.8.1}/unicex/__init__.py +0 -0
  42. {unicex-0.8.0 → unicex-0.8.1}/unicex/_abc/exchange_info.py +0 -0
  43. {unicex-0.8.0 → unicex-0.8.1}/unicex/_abc/uni_websocket_manager.py +0 -0
  44. {unicex-0.8.0 → unicex-0.8.1}/unicex/_base/client.py +0 -0
  45. {unicex-0.8.0 → unicex-0.8.1}/unicex/binance/__init__.py +0 -0
  46. {unicex-0.8.0 → unicex-0.8.1}/unicex/binance/adapter.py +0 -0
  47. {unicex-0.8.0 → unicex-0.8.1}/unicex/binance/exchange_info.py +0 -0
  48. {unicex-0.8.0 → unicex-0.8.1}/unicex/binance/uni_client.py +0 -0
  49. {unicex-0.8.0 → unicex-0.8.1}/unicex/binance/uni_websocket_manager.py +0 -0
  50. {unicex-0.8.0 → unicex-0.8.1}/unicex/binance/user_websocket.py +0 -0
  51. {unicex-0.8.0 → unicex-0.8.1}/unicex/binance/websocket_manager.py +0 -0
  52. {unicex-0.8.0 → unicex-0.8.1}/unicex/bitget/__init__.py +0 -0
  53. {unicex-0.8.0 → unicex-0.8.1}/unicex/bitget/adapter.py +0 -0
  54. {unicex-0.8.0 → unicex-0.8.1}/unicex/bitget/exchange_info.py +0 -0
  55. {unicex-0.8.0 → unicex-0.8.1}/unicex/bitget/uni_client.py +0 -0
  56. {unicex-0.8.0 → unicex-0.8.1}/unicex/bitget/uni_websocket_manager.py +0 -0
  57. {unicex-0.8.0 → unicex-0.8.1}/unicex/bitget/user_websocket.py +0 -0
  58. {unicex-0.8.0 → unicex-0.8.1}/unicex/bitget/websocket_manager.py +0 -0
  59. {unicex-0.8.0 → unicex-0.8.1}/unicex/bybit/__init__.py +0 -0
  60. {unicex-0.8.0 → unicex-0.8.1}/unicex/bybit/adapter.py +0 -0
  61. {unicex-0.8.0 → unicex-0.8.1}/unicex/bybit/exchange_info.py +0 -0
  62. {unicex-0.8.0 → unicex-0.8.1}/unicex/bybit/uni_client.py +0 -0
  63. {unicex-0.8.0 → unicex-0.8.1}/unicex/bybit/uni_websocket_manager.py +0 -0
  64. {unicex-0.8.0 → unicex-0.8.1}/unicex/bybit/user_websocket.py +0 -0
  65. {unicex-0.8.0 → unicex-0.8.1}/unicex/enums.py +0 -0
  66. {unicex-0.8.0 → unicex-0.8.1}/unicex/exceptions.py +0 -0
  67. {unicex-0.8.0 → unicex-0.8.1}/unicex/extra.py +0 -0
  68. {unicex-0.8.0 → unicex-0.8.1}/unicex/gateio/__init__.py +0 -0
  69. {unicex-0.8.0 → unicex-0.8.1}/unicex/gateio/adapter.py +0 -0
  70. {unicex-0.8.0 → unicex-0.8.1}/unicex/gateio/exchange_info.py +0 -0
  71. {unicex-0.8.0 → unicex-0.8.1}/unicex/gateio/uni_client.py +0 -0
  72. {unicex-0.8.0 → unicex-0.8.1}/unicex/gateio/uni_websocket_manager.py +0 -0
  73. {unicex-0.8.0 → unicex-0.8.1}/unicex/gateio/user_websocket.py +0 -0
  74. {unicex-0.8.0 → unicex-0.8.1}/unicex/gateio/websocket_manager.py +0 -0
  75. {unicex-0.8.0 → unicex-0.8.1}/unicex/hyperliquid/__init__.py +0 -0
  76. {unicex-0.8.0 → unicex-0.8.1}/unicex/hyperliquid/adapter.py +0 -0
  77. {unicex-0.8.0 → unicex-0.8.1}/unicex/hyperliquid/client.py +0 -0
  78. {unicex-0.8.0 → unicex-0.8.1}/unicex/hyperliquid/exchange_info.py +0 -0
  79. {unicex-0.8.0 → unicex-0.8.1}/unicex/hyperliquid/uni_client.py +0 -0
  80. {unicex-0.8.0 → unicex-0.8.1}/unicex/hyperliquid/uni_websocket_manager.py +0 -0
  81. {unicex-0.8.0 → unicex-0.8.1}/unicex/hyperliquid/user_websocket.py +0 -0
  82. {unicex-0.8.0 → unicex-0.8.1}/unicex/hyperliquid/websocket_manager.py +0 -0
  83. {unicex-0.8.0 → unicex-0.8.1}/unicex/mapper.py +0 -0
  84. {unicex-0.8.0 → unicex-0.8.1}/unicex/mexc/__init__.py +0 -0
  85. {unicex-0.8.0 → unicex-0.8.1}/unicex/mexc/adapter.py +0 -0
  86. {unicex-0.8.0 → unicex-0.8.1}/unicex/mexc/exchange_info.py +0 -0
  87. {unicex-0.8.0 → unicex-0.8.1}/unicex/mexc/uni_client.py +0 -0
  88. {unicex-0.8.0 → unicex-0.8.1}/unicex/mexc/uni_websocket_manager.py +0 -0
  89. {unicex-0.8.0 → unicex-0.8.1}/unicex/mexc/user_websocket.py +0 -0
  90. {unicex-0.8.0 → unicex-0.8.1}/unicex/mexc/websocket_manager.py +0 -0
  91. {unicex-0.8.0 → unicex-0.8.1}/unicex/okx/__init__.py +0 -0
  92. {unicex-0.8.0 → unicex-0.8.1}/unicex/okx/adapter.py +0 -0
  93. {unicex-0.8.0 → unicex-0.8.1}/unicex/okx/exchange_info.py +0 -0
  94. {unicex-0.8.0 → unicex-0.8.1}/unicex/okx/uni_client.py +0 -0
  95. {unicex-0.8.0 → unicex-0.8.1}/unicex/okx/uni_websocket_manager.py +0 -0
  96. {unicex-0.8.0 → unicex-0.8.1}/unicex/okx/user_websocket.py +0 -0
  97. {unicex-0.8.0 → unicex-0.8.1}/unicex/okx/websocket_manager.py +0 -0
  98. {unicex-0.8.0 → unicex-0.8.1}/unicex/types.py +0 -0
  99. {unicex-0.8.0 → unicex-0.8.1}/unicex/utils.py +0 -0
  100. {unicex-0.8.0 → unicex-0.8.1}/unicex.egg-info/dependency_links.txt +0 -0
  101. {unicex-0.8.0 → unicex-0.8.1}/unicex.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unicex
3
- Version: 0.8.0
3
+ Version: 0.8.1
4
4
  Summary: Unified Crypto Exchange API
5
5
  Author-email: LoveBloodAndDiamonds <ayazshakirzyanov27@gmail.com>
6
6
  License: BSD 3-Clause License
@@ -43,6 +43,7 @@ Requires-Dist: eth-account>=0.13.7
43
43
  Requires-Dist: loguru>=0.7.3
44
44
  Requires-Dist: msgpack>=1.1.1
45
45
  Requires-Dist: orjson>=3.11.3
46
+ Requires-Dist: protobuf>=6.32.1
46
47
  Requires-Dist: websockets>=15.0.1
47
48
  Dynamic: license-file
48
49
 
@@ -56,7 +57,7 @@ Dynamic: license-file
56
57
  |-----------------|--------|------|------------|---------|------------|----------------|--------------|
57
58
  | **Binance** | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | |
58
59
  | **Bitget** | ✓ | ✓ | ✓ | | ✓ | | |
59
- | **Bybit** | ✓ | ✓ | | | ✓ | | |
60
+ | **Bybit** | ✓ | ✓ || | ✓ | | |
60
61
  | **Gateio** | ✓ | ✓ | | | ✓ | | |
61
62
  | **Hyperliquid** | ✓ | ✓ | | | ✓ | | |
62
63
  | **Mexc** | ✓ | ✓ | | | ✓ | | |
@@ -8,7 +8,7 @@
8
8
  |-----------------|--------|------|------------|---------|------------|----------------|--------------|
9
9
  | **Binance** | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | |
10
10
  | **Bitget** | ✓ | ✓ | ✓ | | ✓ | | |
11
- | **Bybit** | ✓ | ✓ | | | ✓ | | |
11
+ | **Bybit** | ✓ | ✓ || | ✓ | | |
12
12
  | **Gateio** | ✓ | ✓ | | | ✓ | | |
13
13
  | **Hyperliquid** | ✓ | ✓ | | | ✓ | | |
14
14
  | **Mexc** | ✓ | ✓ | | | ✓ | | |
@@ -4,7 +4,7 @@ name = "unicex"
4
4
  # • PATCH (x.y.Z) → увеличивается при багфиксе, который не ломает совместимость.
5
5
  # • MINOR (x.Y.z) → увеличивается при добавлении новой функциональности, но без ломающих изменений (backward-compatible).
6
6
  # • MAJOR (X.y.z) → увеличивается при изменениях, которые ломают обратную совместимость.
7
- version = "0.8.0"
7
+ version = "0.8.1"
8
8
 
9
9
  description = "Unified Crypto Exchange API "
10
10
  readme = "README.md"
@@ -19,6 +19,7 @@ dependencies = [
19
19
  "loguru>=0.7.3",
20
20
  "msgpack>=1.1.1",
21
21
  "orjson>=3.11.3",
22
+ "protobuf>=6.32.1",
22
23
  "websockets>=15.0.1",
23
24
  ]
24
25
 
@@ -1,9 +1,13 @@
1
+ """Пакет с абстракциями и интерфейсами."""
2
+
1
3
  __all__ = [
2
4
  "IUniClient",
3
5
  "IUniWebsocketManager",
4
6
  "IExchangeInfo",
7
+ "IDecoder",
5
8
  ]
6
9
 
10
+ from .decoder import IDecoder
7
11
  from .exchange_info import IExchangeInfo
8
12
  from .uni_client import IUniClient
9
13
  from .uni_websocket_manager import IUniWebsocketManager
@@ -0,0 +1,18 @@
1
+ __all__ = ["IDecoder"]
2
+
3
+ from typing import Protocol
4
+
5
+
6
+ class IDecoder(Protocol):
7
+ """Протокол для декодеров сообщений c вебсокета."""
8
+
9
+ def decode(self, message: bytes | str) -> dict:
10
+ """Декодирует сообщение.
11
+
12
+ Параметры:
13
+ message (`Any`): Сообщение для декодирования.
14
+
15
+ Возвращает:
16
+ `Any`: Декодированное сообщение.
17
+ """
18
+ ...
@@ -115,7 +115,7 @@ class IUniClient(ABC, Generic[TClient]):
115
115
  Возвращает:
116
116
  `bool`: True, если апи ключи присутствуют, иначе False.
117
117
  """
118
- return self._client._api_key is not None and self._client._api_secret is not None
118
+ return self._client.is_authorized()
119
119
 
120
120
  async def close_connection(self) -> None:
121
121
  """Закрывает сессию клиента."""
@@ -0,0 +1,9 @@
1
+ """Пакет с базовым клиентом для HTTP запросов и базовым вебсокетом."""
2
+
3
+ __all__ = [
4
+ "BaseClient",
5
+ "Websocket",
6
+ ]
7
+
8
+ from .client import BaseClient
9
+ from .websocket import Websocket
@@ -3,7 +3,7 @@ __all__ = ["Websocket"]
3
3
  import asyncio
4
4
  import time
5
5
  from collections.abc import Awaitable, Callable
6
- from typing import Any
6
+ from typing import TYPE_CHECKING, Any
7
7
 
8
8
  import orjson
9
9
  import websockets
@@ -13,6 +13,9 @@ from websockets.asyncio.client import ClientConnection
13
13
  from unicex.exceptions import QueueOverflowError
14
14
  from unicex.types import LoggerLike
15
15
 
16
+ if TYPE_CHECKING:
17
+ from unicex._abc import IDecoder
18
+
16
19
 
17
20
  class Websocket:
18
21
  """Базовый класс асинхронного вебсокета."""
@@ -20,6 +23,12 @@ class Websocket:
20
23
  MAX_QUEUE_SIZE: int = 100
21
24
  """Максимальная длина очереди."""
22
25
 
26
+ class _JsonDecoder:
27
+ """Базовый JSON декодер для WebSocket сообщений."""
28
+
29
+ def decode(self, message: bytes | str) -> dict:
30
+ return orjson.loads(message)
31
+
23
32
  def __init__(
24
33
  self,
25
34
  callback: Callable[[Any], Awaitable[None]],
@@ -32,6 +41,7 @@ class Websocket:
32
41
  reconnect_timeout: int | float | None = 5,
33
42
  worker_count: int = 2,
34
43
  logger: LoggerLike | None = None,
44
+ decoder: type["IDecoder"] = _JsonDecoder,
35
45
  **kwargs: Any, # Не дадим сломаться, если юзер передал ненужные аргументы
36
46
  ) -> None:
37
47
  """Инициализация вебсокета.
@@ -47,6 +57,7 @@ class Websocket:
47
57
  reconnect_timeout (`int | float | None`): Пауза перед переподключением, сек.
48
58
  worker_count (`int`): Количество рабочих задач для обработки сообщений.
49
59
  logger (`LoggerLike | None`): Логгер для записи логов.
60
+ decoder (`IDecoder | None`): Декодер для обработки входящих сообщений.
50
61
  """
51
62
  self._callback = callback
52
63
  self._url = url
@@ -59,6 +70,7 @@ class Websocket:
59
70
  self._last_message_time = time.monotonic()
60
71
  self._worker_count = worker_count
61
72
  self._logger = logger or _logger
73
+ self._decoder = decoder()
62
74
  self._tasks: list[asyncio.Task] = []
63
75
  self._queue = asyncio.Queue()
64
76
  self._running = False
@@ -114,14 +126,14 @@ class Websocket:
114
126
  await asyncio.sleep(self._reconnect_timeout)
115
127
  await self._after_disconnect()
116
128
 
117
- async def _handle_message(self, message: str) -> None:
129
+ async def _handle_message(self, message: str | bytes) -> None:
118
130
  """Обрабатывает входящее сообщение вебсокета."""
119
131
  try:
120
132
  # Обновленяем время последнего сообщения
121
133
  self._last_message_time = time.monotonic()
122
134
 
123
135
  # Ложим сообщение в очередь, предварительно его сериализуя
124
- await self._queue.put(orjson.loads(message))
136
+ await self._queue.put(self._decoder.decode(message))
125
137
 
126
138
  # Проверяем размер очереди сообщений и выбрасываем ошибку, если он превышает максимальный размер
127
139
  self._check_queue_size()
@@ -139,7 +151,7 @@ class Websocket:
139
151
  """Проверяет размер очереди и выбрасывает ошибку при переполнении."""
140
152
  qsize = self._queue.qsize()
141
153
  if qsize >= self.MAX_QUEUE_SIZE:
142
- raise QueueOverflowError("Message queue is overflow")
154
+ raise QueueOverflowError(f"Message queue is overflow: {qsize}")
143
155
 
144
156
  async def _after_connect(self, conn: ClientConnection) -> None:
145
157
  """Вызывается после установки соединения."""
@@ -62,8 +62,8 @@ class Client(BaseClient):
62
62
  if not signed:
63
63
  return {"params": params, "data": data}, None
64
64
 
65
- if not self._api_key or not self._api_secret: # type: ignore[attr-defined]
66
- raise NotAuthorized("Api key is required to private endpoints")
65
+ if not self.is_authorized():
66
+ raise NotAuthorized("Api key and api secret is required to private endpoints")
67
67
 
68
68
  # Объединяем все параметры в payload
69
69
  payload = {**params, **data}
@@ -5,6 +5,7 @@ import time
5
5
  from typing import Any, Literal
6
6
 
7
7
  from unicex._base import BaseClient
8
+ from unicex.exceptions import NotAuthorized
8
9
  from unicex.types import RequestMethod
9
10
  from unicex.utils import (
10
11
  dict_to_query_string,
@@ -20,6 +21,18 @@ class Client(BaseClient):
20
21
  _BASE_URL: str = "https://api.bitget.com"
21
22
  """Базовый URL для REST API Bitget."""
22
23
 
24
+ def is_authorized(self) -> bool:
25
+ """Проверяет наличие API‑ключей у клиента.
26
+
27
+ Возвращает:
28
+ `bool`: Признак наличия ключей.
29
+ """
30
+ return (
31
+ self._api_key is not None
32
+ and self._api_secret is not None
33
+ and self._api_passphrase is not None
34
+ )
35
+
23
36
  def _sign_message(
24
37
  self,
25
38
  method: RequestMethod,
@@ -45,6 +58,9 @@ class Client(BaseClient):
45
58
  - `timestamp (str)`: Временная метка в миллисекундах.
46
59
  - `signature (str)`: Подпись в формате base64.
47
60
  """
61
+ if not self.is_authorized():
62
+ raise NotAuthorized("Api key and api secret is required to private endpoints")
63
+
48
64
  timestamp = str(int(time.time() * 1000))
49
65
 
50
66
  path = f"{endpoint}?{dict_to_query_string(params)}" if params else endpoint
@@ -68,16 +84,15 @@ class Client(BaseClient):
68
84
  `dict[str, str]`: Словарь заголовков запроса.
69
85
  """
70
86
  headers = {"Content-Type": "application/json", "Accept": "application/json"}
71
- if self._api_key: # type: ignore[attr-defined]
72
- headers.update(
73
- {
74
- "ACCESS-KEY": self._api_key, # type: ignore[attr-defined]
75
- "ACCESS-PASSPHRASE": self._api_passphrase, # type: ignore[attr-defined]
76
- "ACCESS-TIMESTAMP": timestamp,
77
- "ACCESS-SIGN": signature,
78
- "locale": "en-US",
79
- }
80
- )
87
+ headers.update(
88
+ {
89
+ "ACCESS-KEY": self._api_key, # type: ignore[attr-defined]
90
+ "ACCESS-PASSPHRASE": self._api_passphrase, # type: ignore[attr-defined]
91
+ "ACCESS-TIMESTAMP": timestamp,
92
+ "ACCESS-SIGN": signature,
93
+ "locale": "en-US",
94
+ }
95
+ )
81
96
  return headers
82
97
 
83
98
  def _prepare_request_params(
@@ -41,12 +41,12 @@ class Client(BaseClient):
41
41
  Источник: https://github.com/bybit-exchange/api-usage-examples/blob/master/V5_demo/api_demo/Encryption_HMAC.py
42
42
  """
43
43
  # Проверяем наличие апи ключей для подписи запроса
44
- if not self._api_key or not self._api_secret:
45
- raise NotAuthorized("API key and secret are required to private endpoints.")
44
+ if not self.is_authorized():
45
+ raise NotAuthorized("Api key and api secret is required to private endpoints")
46
46
 
47
47
  dumped_payload = json.dumps(payload)
48
- prepared_query_string = timestamp + self._api_key + self._RECV_WINDOW + dumped_payload
49
- return generate_hmac_sha256_signature(self._api_secret, prepared_query_string)
48
+ prepared_query_string = timestamp + self._api_key + self._RECV_WINDOW + dumped_payload # type: ignore[attrDefined]
49
+ return generate_hmac_sha256_signature(self._api_secret, prepared_query_string) # type: ignore[attrDefined]
50
50
 
51
51
  async def _make_request(
52
52
  self,
@@ -0,0 +1,339 @@
1
+ __all__ = ["WebsocketManager"]
2
+
3
+ import json
4
+ import warnings
5
+ from collections.abc import Awaitable, Callable, Sequence
6
+ from typing import Any, Literal
7
+
8
+ from unicex._base import Websocket
9
+
10
+ from .client import Client
11
+
12
+ type CallbackType = Callable[[Any], Awaitable[None]]
13
+
14
+
15
+ class WebsocketManager:
16
+ """Менеджер асинхронных вебсокетов для Bybit."""
17
+
18
+ _BASE_SPOT_URL: str = "wss://stream.bybit.com/v5/public/spot"
19
+ """Базовый URL для вебсокета на спот."""
20
+
21
+ _BASE_LINEAR_URL: str = "wss://stream.bybit.com/v5/public/linear"
22
+ """Базовый URL для вебсокета на USDT/USDC перпетуалы и фьючерсы."""
23
+
24
+ _BASE_INVERSE_URL: str = "wss://stream.bybit.com/v5/public/inverse"
25
+ """Базовый URL для вебсокета на инверсные контракты."""
26
+
27
+ _BASE_OPTION_URL: str = "wss://stream.bybit.com/v5/public/option"
28
+ """Базовый URL для вебсокета на опционы."""
29
+
30
+ _BASE_PRIVATE_URL: str = "wss://stream.bybit.com/v5/private"
31
+ """Базовый URL для приватных вебсокетов."""
32
+
33
+ def __init__(self, client: Client | None = None, **ws_kwargs: Any) -> None:
34
+ """Инициализирует менеджер вебсокетов для Bybit.
35
+
36
+ Параметры:
37
+ client (`Client | None`): Клиент для выполнения запросов. Нужен, чтобы открыть приватные вебсокеты.
38
+ ws_kwargs (`dict[str, Any]`): Дополнительные аргументы, которые прокидываются в `Websocket`.
39
+ """
40
+ self.client = client
41
+ self._ws_kwargs = ws_kwargs
42
+
43
+ def _generate_subscription_message(
44
+ self,
45
+ topics: Sequence[str],
46
+ req_id: str | None = None,
47
+ ) -> list[str]:
48
+ """Сформировать сообщение для подписки на вебсокет.
49
+
50
+ Параметры:
51
+ topics (`Sequence[str]`): Список топиков для подписки.
52
+ req_id (`str | None`): Опциональный идентификатор запроса.
53
+
54
+ Возвращает:
55
+ `list[str]`: Список JSON строк для отправки.
56
+ """
57
+ message = {"op": "subscribe", "args": list(topics)}
58
+ if req_id:
59
+ message["req_id"] = req_id
60
+
61
+ return [json.dumps(message)]
62
+
63
+ def _get_url_for_category(
64
+ self, category: Literal["spot", "linear", "inverse", "option", "private"]
65
+ ) -> str:
66
+ """Получить URL для категории.
67
+
68
+ Параметры:
69
+ category (`Literal["spot", "linear", "inverse", "option", "private"]`): Категория рынка.
70
+
71
+ Возвращает:
72
+ `str`: URL для вебсокета.
73
+ """
74
+ if category == "spot":
75
+ return self._BASE_SPOT_URL
76
+ elif category == "linear":
77
+ return self._BASE_LINEAR_URL
78
+ elif category == "inverse":
79
+ return self._BASE_INVERSE_URL
80
+ elif category == "option":
81
+ return self._BASE_OPTION_URL
82
+ elif category == "private":
83
+ return self._BASE_PRIVATE_URL
84
+ else:
85
+ raise ValueError(f"Unsupported category: {category}")
86
+
87
+ def orderbook(
88
+ self,
89
+ callback: CallbackType,
90
+ category: Literal["spot", "linear", "inverse", "option"],
91
+ depth: Literal[1, 25, 50, 100, 200, 500, 1000] = 1,
92
+ symbol: str | None = None,
93
+ symbols: Sequence[str] | None = None,
94
+ req_id: str | None = None,
95
+ ) -> Websocket:
96
+ """Создает вебсокет для получения данных order book.
97
+
98
+ https://bybit-exchange.github.io/docs/v5/websocket/public/orderbook
99
+
100
+ Параметры:
101
+ callback (`CallbackType`): Асинхронная функция обратного вызова для обработки сообщений.
102
+ category (`Literal["spot", "linear", "inverse", "option"]`): Категория рынка.
103
+ depth (`Literal[1, 25, 50, 100, 200, 500, 1000]`): Глубина order book.
104
+ symbol (`str | None`): Один символ для подписки.
105
+ symbols (`Sequence[str] | None`): Список символов для мультиплекс‑подключения.
106
+ req_id (`str | None`): Опциональный идентификатор запроса.
107
+
108
+ Возвращает:
109
+ `Websocket`: Объект для управления вебсокет соединением.
110
+ """
111
+ if symbol and symbols:
112
+ raise ValueError("Parameters symbol and symbols cannot be used together")
113
+ if not (symbol or symbols):
114
+ raise ValueError("Either symbol or symbols must be provided")
115
+
116
+ tickers = [symbol] if symbol else symbols
117
+ topics = [f"orderbook.{depth}.{ticker.upper()}" for ticker in tickers] # type: ignore
118
+
119
+ subscription_messages = self._generate_subscription_message(topics, req_id)
120
+ url = self._get_url_for_category(category)
121
+
122
+ return Websocket(
123
+ callback=callback,
124
+ url=url,
125
+ subscription_messages=subscription_messages,
126
+ **self._ws_kwargs,
127
+ )
128
+
129
+ def klines(
130
+ self,
131
+ callback: CallbackType,
132
+ category: Literal["spot", "linear", "inverse", "option"],
133
+ interval: Literal[
134
+ "1", "3", "5", "15", "30", "60", "120", "240", "360", "720", "D", "W", "M"
135
+ ],
136
+ symbol: str | None = None,
137
+ symbols: Sequence[str] | None = None,
138
+ req_id: str | None = None,
139
+ ) -> Websocket:
140
+ """Создает вебсокет для получения данных klines (свечей).
141
+
142
+ https://bybit-exchange.github.io/docs/v5/websocket/public/kline
143
+
144
+ Параметры:
145
+ callback (`CallbackType`): Асинхронная функция обратного вызова для обработки сообщений.
146
+ category (`Literal["spot", "linear", "inverse", "option"]`): Категория рынка.
147
+ interval (`Literal["1", "3", "5", "15", "30", "60", "120", "240", "360", "720", "D", "W", "M"]`): Интервал свечи.
148
+ symbol (`str | None`): Один символ для подписки.
149
+ symbols (`Sequence[str] | None`): Список символов для мультиплекс‑подключения.
150
+ req_id (`str | None`): Опциональный идентификатор запроса.
151
+
152
+ Возвращает:
153
+ `Websocket`: Объект для управления вебсокет соединением.
154
+ """
155
+ if symbol and symbols:
156
+ raise ValueError("Parameters symbol and symbols cannot be used together")
157
+ if not (symbol or symbols):
158
+ raise ValueError("Either symbol or symbols must be provided")
159
+
160
+ tickers = [symbol] if symbol else symbols
161
+ topics = [f"kline.{interval}.{ticker.upper()}" for ticker in tickers] # type: ignore
162
+
163
+ subscription_messages = self._generate_subscription_message(topics, req_id)
164
+ url = self._get_url_for_category(category)
165
+
166
+ return Websocket(
167
+ callback=callback,
168
+ url=url,
169
+ subscription_messages=subscription_messages,
170
+ **self._ws_kwargs,
171
+ )
172
+
173
+ def public_trade(
174
+ self,
175
+ callback: CallbackType,
176
+ category: Literal["spot", "linear", "inverse", "option"],
177
+ symbol: str | None = None,
178
+ symbols: Sequence[str] | None = None,
179
+ req_id: str | None = None,
180
+ ) -> Websocket:
181
+ """Создает вебсокет для получения публичных сделок.
182
+
183
+ https://bybit-exchange.github.io/docs/v5/websocket/public/trade
184
+
185
+ Параметры:
186
+ callback (`CallbackType`): Асинхронная функция обратного вызова для обработки сообщений.
187
+ category (`Literal["spot", "linear", "inverse", "option"]`): Категория рынка.
188
+ symbol (`str | None`): Один символ для подписки.
189
+ symbols (`Sequence[str] | None`): Список символов для мультиплекс‑подключения.
190
+ req_id (`str | None`): Опциональный идентификатор запроса.
191
+
192
+ Возвращает:
193
+ `Websocket`: Объект для управления вебсокет соединением.
194
+ """
195
+ if symbol and symbols:
196
+ raise ValueError("Parameters symbol and symbols cannot be used together")
197
+ if not (symbol or symbols):
198
+ raise ValueError("Either symbol or symbols must be provided")
199
+
200
+ tickers = [symbol] if symbol else symbols
201
+ topics = [f"publicTrade.{ticker.upper()}" for ticker in tickers] # type: ignore
202
+
203
+ subscription_messages = self._generate_subscription_message(topics, req_id)
204
+ url = self._get_url_for_category(category)
205
+
206
+ return Websocket(
207
+ callback=callback,
208
+ url=url,
209
+ subscription_messages=subscription_messages,
210
+ **self._ws_kwargs,
211
+ )
212
+
213
+ def ticker(
214
+ self,
215
+ callback: CallbackType,
216
+ category: Literal["spot", "linear", "inverse", "option"],
217
+ symbol: str | None = None,
218
+ symbols: Sequence[str] | None = None,
219
+ req_id: str | None = None,
220
+ ) -> Websocket:
221
+ """Создает вебсокет для получения тикеров.
222
+
223
+ https://bybit-exchange.github.io/docs/v5/websocket/public/ticker
224
+
225
+ Параметры:
226
+ callback (`CallbackType`): Асинхронная функция обратного вызова для обработки сообщений.
227
+ category (`Literal["spot", "linear", "inverse", "option"]`): Категория рынка.
228
+ symbol (`str | None`): Один символ для подписки.
229
+ symbols (`Sequence[str] | None`): Список символов для мультиплекс‑подключения.
230
+ req_id (`str | None`): Опциональный идентификатор запроса.
231
+
232
+ Возвращает:
233
+ `Websocket`: Объект для управления вебсокет соединением.
234
+ """
235
+ if symbol and symbols:
236
+ raise ValueError("Parameters symbol and symbols cannot be used together")
237
+ if not (symbol or symbols):
238
+ raise ValueError("Either symbol or symbols must be provided")
239
+
240
+ tickers = [symbol] if symbol else symbols
241
+ topics = [f"tickers.{ticker.upper()}" for ticker in tickers] # type: ignore
242
+
243
+ subscription_messages = self._generate_subscription_message(topics, req_id)
244
+ url = self._get_url_for_category(category)
245
+
246
+ return Websocket(
247
+ callback=callback,
248
+ url=url,
249
+ subscription_messages=subscription_messages,
250
+ **self._ws_kwargs,
251
+ )
252
+
253
+ def liquidation(
254
+ self,
255
+ callback: CallbackType,
256
+ category: Literal["linear", "inverse"],
257
+ symbol: str | None = None,
258
+ symbols: Sequence[str] | None = None,
259
+ req_id: str | None = None,
260
+ ) -> Websocket:
261
+ """Создает вебсокет для получения данных ликвидаций.
262
+
263
+ https://bybit-exchange.github.io/docs/v5/websocket/public/liquidation
264
+
265
+ Параметры:
266
+ callback (`CallbackType`): Асинхронная функция обратного вызова для обработки сообщений.
267
+ category (`Literal["linear", "inverse"]`): Категория рынка (только для деривативов).
268
+ symbol (`str | None`): Один символ для подписки.
269
+ symbols (`Sequence[str] | None`): Список символов для мультиплекс‑подключения.
270
+ req_id (`str | None`): Опциональный идентификатор запроса.
271
+
272
+ Возвращает:
273
+ `Websocket`: Объект для управления вебсокет соединением.
274
+ """
275
+ warnings.warn(
276
+ "TDepreicated liquidation stream, please move to All Liquidation Subscribe"
277
+ "to the liquidation stream. Pushes at most one order per second per symbol."
278
+ "As such, this feed does not push all liquidations that occur on Bybit.",
279
+ DeprecationWarning,
280
+ stacklevel=2,
281
+ )
282
+
283
+ if symbol and symbols:
284
+ raise ValueError("Parameters symbol and symbols cannot be used together")
285
+ if not (symbol or symbols):
286
+ raise ValueError("Either symbol or symbols must be provided")
287
+
288
+ tickers = [symbol] if symbol else symbols
289
+ topics = [f"liquidation.{ticker.upper()}" for ticker in tickers] # type: ignore
290
+
291
+ subscription_messages = self._generate_subscription_message(topics, req_id)
292
+ url = self._get_url_for_category(category)
293
+
294
+ return Websocket(
295
+ callback=callback,
296
+ url=url,
297
+ subscription_messages=subscription_messages,
298
+ **self._ws_kwargs,
299
+ )
300
+
301
+ def all_liquidation(
302
+ self,
303
+ callback: CallbackType,
304
+ category: Literal["linear", "inverse"],
305
+ symbol: str | None = None,
306
+ symbols: Sequence[str] | None = None,
307
+ req_id: str | None = None,
308
+ ) -> Websocket:
309
+ """Создает вебсокет для получения данных ликвидаций.
310
+
311
+ https://bybit-exchange.github.io/docs/v5/websocket/public/all-liquidation
312
+
313
+ Параметры:
314
+ callback (`CallbackType`): Асинхронная функция обратного вызова для обработки сообщений.
315
+ category (`Literal["linear", "inverse"]`): Категория рынка (только для деривативов).
316
+ symbol (`str | None`): Один символ для подписки.
317
+ symbols (`Sequence[str] | None`): Список символов для мультиплекс‑подключения.
318
+ req_id (`str | None`): Опциональный идентификатор запроса.
319
+
320
+ Возвращает:
321
+ `Websocket`: Объект для управления вебсокет соединением.
322
+ """
323
+ if symbol and symbols:
324
+ raise ValueError("Parameters symbol and symbols cannot be used together")
325
+ if not (symbol or symbols):
326
+ raise ValueError("Either symbol or symbols must be provided")
327
+
328
+ tickers = [symbol] if symbol else symbols
329
+ topics = [f"allLiquidation.{ticker.upper()}" for ticker in tickers] # type: ignore
330
+
331
+ subscription_messages = self._generate_subscription_message(topics, req_id)
332
+ url = self._get_url_for_category(category)
333
+
334
+ return Websocket(
335
+ callback=callback,
336
+ url=url,
337
+ subscription_messages=subscription_messages,
338
+ **self._ws_kwargs,
339
+ )
@@ -45,7 +45,7 @@ class Client(BaseClient):
45
45
  return url, params, data, headers
46
46
 
47
47
  if not self.is_authorized():
48
- raise NotAuthorized("Api key is required to private endpoints")
48
+ raise NotAuthorized("Api key and api secret is required to private endpoints")
49
49
 
50
50
  payload_string = json.dumps(data, separators=(",", ":")) if data else ""
51
51
  query_string = dict_to_query_string(params) if params else ""