pulse-bybit 0.1.0__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.
- pulse_bybit-0.1.0/PKG-INFO +25 -0
- pulse_bybit-0.1.0/pulse_bybit/__init__.py +22 -0
- pulse_bybit-0.1.0/pulse_bybit/adapter.py +389 -0
- pulse_bybit-0.1.0/pulse_bybit/version.py +4 -0
- pulse_bybit-0.1.0/pulse_bybit.egg-info/PKG-INFO +25 -0
- pulse_bybit-0.1.0/pulse_bybit.egg-info/SOURCES.txt +10 -0
- pulse_bybit-0.1.0/pulse_bybit.egg-info/dependency_links.txt +1 -0
- pulse_bybit-0.1.0/pulse_bybit.egg-info/requires.txt +6 -0
- pulse_bybit-0.1.0/pulse_bybit.egg-info/top_level.txt +1 -0
- pulse_bybit-0.1.0/pyproject.toml +49 -0
- pulse_bybit-0.1.0/setup.cfg +4 -0
- pulse_bybit-0.1.0/tests/test_bybit_adapter.py +401 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pulse-bybit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Bybit adapter for PULSE Protocol — trade with PULSE messages
|
|
5
|
+
Author-email: PULSE Protocol Team <pulse@protocol.org>
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/pulseprotocolorg-cyber/pulse-bybit
|
|
8
|
+
Project-URL: Repository, https://github.com/pulseprotocolorg-cyber/pulse-bybit
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Classifier: Topic :: Office/Business :: Financial :: Investment
|
|
19
|
+
Requires-Python: >=3.8
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Requires-Dist: pulse-protocol>=0.5.0
|
|
22
|
+
Requires-Dist: requests>=2.28.0
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
25
|
+
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PULSE-Bybit Adapter.
|
|
3
|
+
|
|
4
|
+
Bridge PULSE Protocol messages to Bybit V5 API.
|
|
5
|
+
Same interface as pulse-binance — swap exchanges in one line.
|
|
6
|
+
|
|
7
|
+
Example:
|
|
8
|
+
>>> from pulse_bybit import BybitAdapter
|
|
9
|
+
>>> adapter = BybitAdapter(api_key="...", api_secret="...")
|
|
10
|
+
>>> from pulse import PulseMessage
|
|
11
|
+
>>> msg = PulseMessage(
|
|
12
|
+
... action="ACT.QUERY.DATA",
|
|
13
|
+
... parameters={"symbol": "BTCUSDT"}
|
|
14
|
+
... )
|
|
15
|
+
>>> response = adapter.send(msg)
|
|
16
|
+
>>> print(response.content["parameters"]["result"]["price"])
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from pulse_bybit.adapter import BybitAdapter
|
|
20
|
+
from pulse_bybit.version import __version__
|
|
21
|
+
|
|
22
|
+
__all__ = ["BybitAdapter", "__version__"]
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"""Bybit V5 adapter for PULSE Protocol.
|
|
2
|
+
|
|
3
|
+
Translates PULSE semantic messages to Bybit V5 unified API.
|
|
4
|
+
Same interface as BinanceAdapter — swap exchanges in one line.
|
|
5
|
+
|
|
6
|
+
Example:
|
|
7
|
+
>>> adapter = BybitAdapter(api_key="...", api_secret="...")
|
|
8
|
+
>>> msg = PulseMessage(
|
|
9
|
+
... action="ACT.QUERY.DATA",
|
|
10
|
+
... parameters={"symbol": "BTCUSDT"}
|
|
11
|
+
... )
|
|
12
|
+
>>> response = adapter.send(msg)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import hmac
|
|
17
|
+
import time
|
|
18
|
+
from typing import Any, Dict, List, Optional
|
|
19
|
+
|
|
20
|
+
import requests
|
|
21
|
+
|
|
22
|
+
from pulse.message import PulseMessage
|
|
23
|
+
from pulse.adapter import PulseAdapter, AdapterError, AdapterConnectionError
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# Bybit V5 API endpoints
|
|
27
|
+
ENDPOINTS = {
|
|
28
|
+
"tickers": "/v5/market/tickers",
|
|
29
|
+
"kline": "/v5/market/kline",
|
|
30
|
+
"orderbook": "/v5/market/orderbook",
|
|
31
|
+
"server_time": "/v5/market/time",
|
|
32
|
+
"place_order": "/v5/order/create",
|
|
33
|
+
"cancel_order": "/v5/order/cancel",
|
|
34
|
+
"order_detail": "/v5/order/realtime",
|
|
35
|
+
"open_orders": "/v5/order/realtime",
|
|
36
|
+
"wallet_balance": "/v5/account/wallet-balance",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# Map PULSE actions to Bybit operations
|
|
40
|
+
ACTION_MAP = {
|
|
41
|
+
"ACT.QUERY.DATA": "query",
|
|
42
|
+
"ACT.QUERY.STATUS": "order_status",
|
|
43
|
+
"ACT.TRANSACT.REQUEST": "place_order",
|
|
44
|
+
"ACT.CANCEL": "cancel_order",
|
|
45
|
+
"ACT.QUERY.LIST": "open_orders",
|
|
46
|
+
"ACT.QUERY.BALANCE": "wallet_balance",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class BybitAdapter(PulseAdapter):
|
|
51
|
+
"""PULSE adapter for Bybit exchange (V5 API).
|
|
52
|
+
|
|
53
|
+
Translates PULSE semantic actions to Bybit V5 unified API.
|
|
54
|
+
Same interface as BinanceAdapter — switch exchanges in one line.
|
|
55
|
+
|
|
56
|
+
Supported PULSE actions:
|
|
57
|
+
- ACT.QUERY.DATA — get ticker price, klines, order book
|
|
58
|
+
- ACT.QUERY.STATUS — check order status
|
|
59
|
+
- ACT.QUERY.LIST — list open orders
|
|
60
|
+
- ACT.QUERY.BALANCE — get wallet balance
|
|
61
|
+
- ACT.TRANSACT.REQUEST — place an order (BUY/SELL)
|
|
62
|
+
- ACT.CANCEL — cancel an order
|
|
63
|
+
|
|
64
|
+
Example:
|
|
65
|
+
>>> # Switch from Binance to Bybit — one line change
|
|
66
|
+
>>> # adapter = BinanceAdapter(api_key="...", api_secret="...")
|
|
67
|
+
>>> adapter = BybitAdapter(api_key="...", api_secret="...")
|
|
68
|
+
>>> msg = PulseMessage(
|
|
69
|
+
... action="ACT.QUERY.DATA",
|
|
70
|
+
... parameters={"symbol": "BTCUSDT"}
|
|
71
|
+
... )
|
|
72
|
+
>>> response = adapter.send(msg)
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
BASE_URL = "https://api.bybit.com"
|
|
76
|
+
TESTNET_URL = "https://api-testnet.bybit.com"
|
|
77
|
+
|
|
78
|
+
def __init__(
|
|
79
|
+
self,
|
|
80
|
+
api_key: Optional[str] = None,
|
|
81
|
+
api_secret: Optional[str] = None,
|
|
82
|
+
testnet: bool = False,
|
|
83
|
+
config: Optional[Dict[str, Any]] = None,
|
|
84
|
+
) -> None:
|
|
85
|
+
base_url = self.TESTNET_URL if testnet else self.BASE_URL
|
|
86
|
+
super().__init__(
|
|
87
|
+
name="bybit",
|
|
88
|
+
base_url=base_url,
|
|
89
|
+
config=config or {},
|
|
90
|
+
)
|
|
91
|
+
self._api_key = api_key
|
|
92
|
+
self._api_secret = api_secret
|
|
93
|
+
self._testnet = testnet
|
|
94
|
+
self._session: Optional[requests.Session] = None
|
|
95
|
+
self._recv_window = "5000"
|
|
96
|
+
|
|
97
|
+
def connect(self) -> None:
|
|
98
|
+
"""Initialize HTTP session and verify connectivity."""
|
|
99
|
+
self._session = requests.Session()
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
resp = self._session.get(f"{self.base_url}{ENDPOINTS['server_time']}", timeout=10)
|
|
103
|
+
resp.raise_for_status()
|
|
104
|
+
self.connected = True
|
|
105
|
+
except requests.ConnectionError as e:
|
|
106
|
+
raise AdapterConnectionError(f"Cannot reach Bybit API: {e}") from e
|
|
107
|
+
except requests.HTTPError as e:
|
|
108
|
+
raise AdapterConnectionError(f"Bybit API error: {e}") from e
|
|
109
|
+
|
|
110
|
+
def disconnect(self) -> None:
|
|
111
|
+
"""Close HTTP session."""
|
|
112
|
+
if self._session:
|
|
113
|
+
self._session.close()
|
|
114
|
+
self._session = None
|
|
115
|
+
self.connected = False
|
|
116
|
+
|
|
117
|
+
def to_native(self, message: PulseMessage) -> Dict[str, Any]:
|
|
118
|
+
"""Convert PULSE message to Bybit API request."""
|
|
119
|
+
action = message.content["action"]
|
|
120
|
+
params = message.content.get("parameters", {})
|
|
121
|
+
operation = ACTION_MAP.get(action)
|
|
122
|
+
|
|
123
|
+
if not operation:
|
|
124
|
+
raise AdapterError(
|
|
125
|
+
f"Unsupported action '{action}'. Supported: {list(ACTION_MAP.keys())}"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
if operation == "query":
|
|
129
|
+
return self._build_query_request(params)
|
|
130
|
+
elif operation == "place_order":
|
|
131
|
+
return self._build_order_request(params)
|
|
132
|
+
elif operation == "cancel_order":
|
|
133
|
+
return self._build_cancel_request(params)
|
|
134
|
+
elif operation == "order_status":
|
|
135
|
+
return self._build_status_request(params)
|
|
136
|
+
elif operation == "open_orders":
|
|
137
|
+
return self._build_open_orders_request(params)
|
|
138
|
+
elif operation == "wallet_balance":
|
|
139
|
+
return self._build_balance_request(params)
|
|
140
|
+
|
|
141
|
+
raise AdapterError(f"Unknown operation: {operation}")
|
|
142
|
+
|
|
143
|
+
def call_api(self, native_request: Dict[str, Any]) -> Dict[str, Any]:
|
|
144
|
+
"""Execute Bybit API call."""
|
|
145
|
+
if not self._session:
|
|
146
|
+
self._ensure_session()
|
|
147
|
+
|
|
148
|
+
method = native_request["method"]
|
|
149
|
+
url = f"{self.base_url}{native_request['endpoint']}"
|
|
150
|
+
params = native_request.get("params", {})
|
|
151
|
+
signed = native_request.get("signed", False)
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
if method == "GET":
|
|
155
|
+
headers = self._sign_get(params) if signed else {}
|
|
156
|
+
resp = self._session.get(url, params=params, headers=headers, timeout=10)
|
|
157
|
+
elif method == "POST":
|
|
158
|
+
headers = self._sign_post(params) if signed else {"Content-Type": "application/json"}
|
|
159
|
+
resp = self._session.post(url, json=params, headers=headers, timeout=10)
|
|
160
|
+
else:
|
|
161
|
+
raise AdapterError(f"Unknown HTTP method: {method}")
|
|
162
|
+
|
|
163
|
+
data = resp.json()
|
|
164
|
+
|
|
165
|
+
# Bybit V5 uses retCode for errors
|
|
166
|
+
ret_code = data.get("retCode", 0)
|
|
167
|
+
if ret_code != 0:
|
|
168
|
+
ret_msg = data.get("retMsg", "Unknown error")
|
|
169
|
+
raise AdapterError(f"Bybit error {ret_code}: {ret_msg}")
|
|
170
|
+
|
|
171
|
+
return data.get("result", data)
|
|
172
|
+
|
|
173
|
+
except (requests.ConnectionError, ConnectionError) as e:
|
|
174
|
+
raise AdapterConnectionError(f"Cannot reach Bybit: {e}") from e
|
|
175
|
+
except (requests.Timeout, TimeoutError) as e:
|
|
176
|
+
raise AdapterConnectionError(f"Bybit request timed out: {e}") from e
|
|
177
|
+
except AdapterError:
|
|
178
|
+
raise
|
|
179
|
+
except Exception as e:
|
|
180
|
+
raise AdapterError(f"Bybit request failed: {e}") from e
|
|
181
|
+
|
|
182
|
+
def from_native(self, native_response: Any) -> PulseMessage:
|
|
183
|
+
"""Convert Bybit response to PULSE message."""
|
|
184
|
+
return PulseMessage(
|
|
185
|
+
action="ACT.RESPOND",
|
|
186
|
+
parameters={"result": native_response},
|
|
187
|
+
validate=False,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def supported_actions(self) -> List[str]:
|
|
192
|
+
return list(ACTION_MAP.keys())
|
|
193
|
+
|
|
194
|
+
# --- Request Builders ---
|
|
195
|
+
|
|
196
|
+
def _build_query_request(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
197
|
+
"""Build market data query."""
|
|
198
|
+
symbol = params.get("symbol")
|
|
199
|
+
query_type = params.get("type", "price")
|
|
200
|
+
category = params.get("category", "spot")
|
|
201
|
+
|
|
202
|
+
if query_type in ("price", "24h"):
|
|
203
|
+
req_params = {"category": category}
|
|
204
|
+
if symbol:
|
|
205
|
+
req_params["symbol"] = symbol.upper()
|
|
206
|
+
return {
|
|
207
|
+
"method": "GET",
|
|
208
|
+
"endpoint": ENDPOINTS["tickers"],
|
|
209
|
+
"params": req_params,
|
|
210
|
+
"signed": False,
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
elif query_type == "klines":
|
|
214
|
+
if not symbol:
|
|
215
|
+
raise AdapterError("Symbol required for klines query.")
|
|
216
|
+
return {
|
|
217
|
+
"method": "GET",
|
|
218
|
+
"endpoint": ENDPOINTS["kline"],
|
|
219
|
+
"params": {
|
|
220
|
+
"category": category,
|
|
221
|
+
"symbol": symbol.upper(),
|
|
222
|
+
"interval": params.get("interval", "60"),
|
|
223
|
+
"limit": params.get("limit", 100),
|
|
224
|
+
},
|
|
225
|
+
"signed": False,
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
elif query_type == "depth":
|
|
229
|
+
if not symbol:
|
|
230
|
+
raise AdapterError("Symbol required for depth query.")
|
|
231
|
+
return {
|
|
232
|
+
"method": "GET",
|
|
233
|
+
"endpoint": ENDPOINTS["orderbook"],
|
|
234
|
+
"params": {
|
|
235
|
+
"category": category,
|
|
236
|
+
"symbol": symbol.upper(),
|
|
237
|
+
"limit": params.get("limit", 20),
|
|
238
|
+
},
|
|
239
|
+
"signed": False,
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
raise AdapterError(f"Unknown query type '{query_type}'. Use: price, 24h, klines, depth.")
|
|
243
|
+
|
|
244
|
+
def _build_order_request(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
245
|
+
"""Build order placement request."""
|
|
246
|
+
required = ["symbol", "side", "quantity"]
|
|
247
|
+
for field in required:
|
|
248
|
+
if field not in params:
|
|
249
|
+
raise AdapterError(f"Missing required field '{field}' for order placement.")
|
|
250
|
+
|
|
251
|
+
order_params = {
|
|
252
|
+
"category": params.get("category", "spot"),
|
|
253
|
+
"symbol": params["symbol"].upper(),
|
|
254
|
+
"side": "Buy" if params["side"].upper() == "BUY" else "Sell",
|
|
255
|
+
"orderType": params.get("order_type", "Market"),
|
|
256
|
+
"qty": str(params["quantity"]),
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if order_params["orderType"].upper() == "LIMIT":
|
|
260
|
+
if "price" not in params:
|
|
261
|
+
raise AdapterError("Price required for LIMIT orders.")
|
|
262
|
+
order_params["orderType"] = "Limit"
|
|
263
|
+
order_params["price"] = str(params["price"])
|
|
264
|
+
order_params["timeInForce"] = params.get("time_in_force", "GTC")
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
"method": "POST",
|
|
268
|
+
"endpoint": ENDPOINTS["place_order"],
|
|
269
|
+
"params": order_params,
|
|
270
|
+
"signed": True,
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
def _build_cancel_request(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
274
|
+
"""Build order cancellation request."""
|
|
275
|
+
if "symbol" not in params:
|
|
276
|
+
raise AdapterError("Symbol required for order cancellation.")
|
|
277
|
+
if "order_id" not in params:
|
|
278
|
+
raise AdapterError("Order ID required for cancellation.")
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
"method": "POST",
|
|
282
|
+
"endpoint": ENDPOINTS["cancel_order"],
|
|
283
|
+
"params": {
|
|
284
|
+
"category": params.get("category", "spot"),
|
|
285
|
+
"symbol": params["symbol"].upper(),
|
|
286
|
+
"orderId": str(params["order_id"]),
|
|
287
|
+
},
|
|
288
|
+
"signed": True,
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
def _build_status_request(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
292
|
+
"""Build order status query."""
|
|
293
|
+
if "symbol" not in params:
|
|
294
|
+
raise AdapterError("Symbol required for order status query.")
|
|
295
|
+
if "order_id" not in params:
|
|
296
|
+
raise AdapterError("Order ID required for status query.")
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
"method": "GET",
|
|
300
|
+
"endpoint": ENDPOINTS["order_detail"],
|
|
301
|
+
"params": {
|
|
302
|
+
"category": params.get("category", "spot"),
|
|
303
|
+
"symbol": params["symbol"].upper(),
|
|
304
|
+
"orderId": str(params["order_id"]),
|
|
305
|
+
},
|
|
306
|
+
"signed": True,
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
def _build_open_orders_request(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
310
|
+
"""Build open orders query."""
|
|
311
|
+
req_params = {"category": params.get("category", "spot")}
|
|
312
|
+
if "symbol" in params:
|
|
313
|
+
req_params["symbol"] = params["symbol"].upper()
|
|
314
|
+
|
|
315
|
+
return {
|
|
316
|
+
"method": "GET",
|
|
317
|
+
"endpoint": ENDPOINTS["open_orders"],
|
|
318
|
+
"params": req_params,
|
|
319
|
+
"signed": True,
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
def _build_balance_request(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
323
|
+
"""Build wallet balance query."""
|
|
324
|
+
return {
|
|
325
|
+
"method": "GET",
|
|
326
|
+
"endpoint": ENDPOINTS["wallet_balance"],
|
|
327
|
+
"params": {
|
|
328
|
+
"accountType": params.get("account_type", "UNIFIED"),
|
|
329
|
+
},
|
|
330
|
+
"signed": True,
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
# --- Signing ---
|
|
334
|
+
|
|
335
|
+
def _sign_get(self, params: Dict[str, Any]) -> Dict[str, str]:
|
|
336
|
+
"""Generate authentication headers for GET requests."""
|
|
337
|
+
if not self._api_key or not self._api_secret:
|
|
338
|
+
raise AdapterError("API key and secret required for signed requests.")
|
|
339
|
+
|
|
340
|
+
timestamp = str(int(time.time() * 1000))
|
|
341
|
+
param_str = "&".join(f"{k}={v}" for k, v in sorted(params.items()))
|
|
342
|
+
sign_str = f"{timestamp}{self._api_key}{self._recv_window}{param_str}"
|
|
343
|
+
|
|
344
|
+
signature = hmac.new(
|
|
345
|
+
self._api_secret.encode("utf-8"),
|
|
346
|
+
sign_str.encode("utf-8"),
|
|
347
|
+
hashlib.sha256,
|
|
348
|
+
).hexdigest()
|
|
349
|
+
|
|
350
|
+
return {
|
|
351
|
+
"X-BAPI-API-KEY": self._api_key,
|
|
352
|
+
"X-BAPI-SIGN": signature,
|
|
353
|
+
"X-BAPI-TIMESTAMP": timestamp,
|
|
354
|
+
"X-BAPI-RECV-WINDOW": self._recv_window,
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
def _sign_post(self, params: Dict[str, Any]) -> Dict[str, str]:
|
|
358
|
+
"""Generate authentication headers for POST requests."""
|
|
359
|
+
if not self._api_key or not self._api_secret:
|
|
360
|
+
raise AdapterError("API key and secret required for signed requests.")
|
|
361
|
+
|
|
362
|
+
import json
|
|
363
|
+
timestamp = str(int(time.time() * 1000))
|
|
364
|
+
param_str = json.dumps(params)
|
|
365
|
+
sign_str = f"{timestamp}{self._api_key}{self._recv_window}{param_str}"
|
|
366
|
+
|
|
367
|
+
signature = hmac.new(
|
|
368
|
+
self._api_secret.encode("utf-8"),
|
|
369
|
+
sign_str.encode("utf-8"),
|
|
370
|
+
hashlib.sha256,
|
|
371
|
+
).hexdigest()
|
|
372
|
+
|
|
373
|
+
return {
|
|
374
|
+
"X-BAPI-API-KEY": self._api_key,
|
|
375
|
+
"X-BAPI-SIGN": signature,
|
|
376
|
+
"X-BAPI-TIMESTAMP": timestamp,
|
|
377
|
+
"X-BAPI-RECV-WINDOW": self._recv_window,
|
|
378
|
+
"Content-Type": "application/json",
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
def _ensure_session(self) -> None:
|
|
382
|
+
if not self._session:
|
|
383
|
+
self._session = requests.Session()
|
|
384
|
+
|
|
385
|
+
def __repr__(self) -> str:
|
|
386
|
+
return (
|
|
387
|
+
f"BybitAdapter(testnet={self._testnet}, "
|
|
388
|
+
f"connected={self.connected})"
|
|
389
|
+
)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pulse-bybit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Bybit adapter for PULSE Protocol — trade with PULSE messages
|
|
5
|
+
Author-email: PULSE Protocol Team <pulse@protocol.org>
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/pulseprotocolorg-cyber/pulse-bybit
|
|
8
|
+
Project-URL: Repository, https://github.com/pulseprotocolorg-cyber/pulse-bybit
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Classifier: Topic :: Office/Business :: Financial :: Investment
|
|
19
|
+
Requires-Python: >=3.8
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Requires-Dist: pulse-protocol>=0.5.0
|
|
22
|
+
Requires-Dist: requests>=2.28.0
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
25
|
+
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
pyproject.toml
|
|
2
|
+
pulse_bybit/__init__.py
|
|
3
|
+
pulse_bybit/adapter.py
|
|
4
|
+
pulse_bybit/version.py
|
|
5
|
+
pulse_bybit.egg-info/PKG-INFO
|
|
6
|
+
pulse_bybit.egg-info/SOURCES.txt
|
|
7
|
+
pulse_bybit.egg-info/dependency_links.txt
|
|
8
|
+
pulse_bybit.egg-info/requires.txt
|
|
9
|
+
pulse_bybit.egg-info/top_level.txt
|
|
10
|
+
tests/test_bybit_adapter.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pulse_bybit
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=64", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pulse-bybit"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Bybit adapter for PULSE Protocol — trade with PULSE messages"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
requires-python = ">=3.8"
|
|
12
|
+
authors = [
|
|
13
|
+
{name = "PULSE Protocol Team", email = "pulse@protocol.org"},
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.8",
|
|
20
|
+
"Programming Language :: Python :: 3.9",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
25
|
+
"Topic :: Office/Business :: Financial :: Investment",
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"pulse-protocol>=0.5.0",
|
|
29
|
+
"requests>=2.28.0",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
dev = [
|
|
34
|
+
"pytest>=7.0",
|
|
35
|
+
"pytest-cov>=4.0",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
Homepage = "https://github.com/pulseprotocolorg-cyber/pulse-bybit"
|
|
40
|
+
Repository = "https://github.com/pulseprotocolorg-cyber/pulse-bybit"
|
|
41
|
+
|
|
42
|
+
[tool.setuptools.packages.find]
|
|
43
|
+
include = ["pulse_bybit*"]
|
|
44
|
+
|
|
45
|
+
[tool.pytest.ini_options]
|
|
46
|
+
testpaths = ["tests"]
|
|
47
|
+
|
|
48
|
+
[tool.black]
|
|
49
|
+
line-length = 100
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
"""Tests for Bybit adapter. All mocked — no real API calls."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from unittest.mock import MagicMock, patch
|
|
5
|
+
|
|
6
|
+
from pulse.message import PulseMessage
|
|
7
|
+
from pulse.adapter import AdapterError, AdapterConnectionError
|
|
8
|
+
|
|
9
|
+
from pulse_bybit import BybitAdapter
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# --- Mock Helpers ---
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def mock_response(result_data, ret_code=0, ret_msg="OK"):
|
|
16
|
+
"""Create a mock Bybit V5 response."""
|
|
17
|
+
mock = MagicMock()
|
|
18
|
+
mock.status_code = 200
|
|
19
|
+
mock.json.return_value = {
|
|
20
|
+
"retCode": ret_code,
|
|
21
|
+
"retMsg": ret_msg,
|
|
22
|
+
"result": result_data,
|
|
23
|
+
}
|
|
24
|
+
mock.raise_for_status.return_value = None
|
|
25
|
+
return mock
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# --- Fixtures ---
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@pytest.fixture
|
|
32
|
+
def adapter():
|
|
33
|
+
a = BybitAdapter(api_key="test-key", api_secret="test-secret")
|
|
34
|
+
a._session = MagicMock()
|
|
35
|
+
a.connected = True
|
|
36
|
+
return a
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@pytest.fixture
|
|
40
|
+
def price_message():
|
|
41
|
+
return PulseMessage(
|
|
42
|
+
action="ACT.QUERY.DATA",
|
|
43
|
+
parameters={"symbol": "BTCUSDT"},
|
|
44
|
+
sender="test-bot",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@pytest.fixture
|
|
49
|
+
def klines_message():
|
|
50
|
+
return PulseMessage(
|
|
51
|
+
action="ACT.QUERY.DATA",
|
|
52
|
+
parameters={"symbol": "BTCUSDT", "type": "klines", "interval": "60"},
|
|
53
|
+
sender="test-bot",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@pytest.fixture
|
|
58
|
+
def buy_message():
|
|
59
|
+
return PulseMessage(
|
|
60
|
+
action="ACT.TRANSACT.REQUEST",
|
|
61
|
+
parameters={"symbol": "BTCUSDT", "side": "BUY", "quantity": 0.001},
|
|
62
|
+
sender="test-bot",
|
|
63
|
+
validate=False,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@pytest.fixture
|
|
68
|
+
def cancel_message():
|
|
69
|
+
return PulseMessage(
|
|
70
|
+
action="ACT.CANCEL",
|
|
71
|
+
parameters={"symbol": "BTCUSDT", "order_id": "abc123"},
|
|
72
|
+
sender="test-bot",
|
|
73
|
+
validate=False,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@pytest.fixture
|
|
78
|
+
def status_message():
|
|
79
|
+
return PulseMessage(
|
|
80
|
+
action="ACT.QUERY.STATUS",
|
|
81
|
+
parameters={"symbol": "BTCUSDT", "order_id": "abc123"},
|
|
82
|
+
sender="test-bot",
|
|
83
|
+
validate=False,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@pytest.fixture
|
|
88
|
+
def balance_message():
|
|
89
|
+
return PulseMessage(
|
|
90
|
+
action="ACT.QUERY.BALANCE",
|
|
91
|
+
parameters={},
|
|
92
|
+
sender="test-bot",
|
|
93
|
+
validate=False,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# --- Test Initialization ---
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class TestBybitAdapterInit:
|
|
101
|
+
|
|
102
|
+
def test_basic_init(self):
|
|
103
|
+
adapter = BybitAdapter(api_key="key", api_secret="secret")
|
|
104
|
+
assert adapter.name == "bybit"
|
|
105
|
+
assert adapter.base_url == "https://api.bybit.com"
|
|
106
|
+
assert adapter.connected is False
|
|
107
|
+
|
|
108
|
+
def test_testnet_init(self):
|
|
109
|
+
adapter = BybitAdapter(testnet=True)
|
|
110
|
+
assert adapter.base_url == "https://api-testnet.bybit.com"
|
|
111
|
+
|
|
112
|
+
def test_repr(self):
|
|
113
|
+
adapter = BybitAdapter()
|
|
114
|
+
assert "testnet=False" in repr(adapter)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# --- Test to_native: Market Data ---
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class TestToNativeMarketData:
|
|
121
|
+
|
|
122
|
+
def test_price_query(self, adapter, price_message):
|
|
123
|
+
native = adapter.to_native(price_message)
|
|
124
|
+
assert native["method"] == "GET"
|
|
125
|
+
assert native["endpoint"] == "/v5/market/tickers"
|
|
126
|
+
assert native["params"]["symbol"] == "BTCUSDT"
|
|
127
|
+
assert native["params"]["category"] == "spot"
|
|
128
|
+
assert native["signed"] is False
|
|
129
|
+
|
|
130
|
+
def test_klines_query(self, adapter, klines_message):
|
|
131
|
+
native = adapter.to_native(klines_message)
|
|
132
|
+
assert native["endpoint"] == "/v5/market/kline"
|
|
133
|
+
assert native["params"]["interval"] == "60"
|
|
134
|
+
|
|
135
|
+
def test_depth_query(self, adapter):
|
|
136
|
+
msg = PulseMessage(
|
|
137
|
+
action="ACT.QUERY.DATA",
|
|
138
|
+
parameters={"symbol": "BTCUSDT", "type": "depth"},
|
|
139
|
+
)
|
|
140
|
+
native = adapter.to_native(msg)
|
|
141
|
+
assert native["endpoint"] == "/v5/market/orderbook"
|
|
142
|
+
|
|
143
|
+
def test_symbol_uppercased(self, adapter):
|
|
144
|
+
msg = PulseMessage(action="ACT.QUERY.DATA", parameters={"symbol": "btcusdt"})
|
|
145
|
+
native = adapter.to_native(msg)
|
|
146
|
+
assert native["params"]["symbol"] == "BTCUSDT"
|
|
147
|
+
|
|
148
|
+
def test_unknown_query_type_raises(self, adapter):
|
|
149
|
+
msg = PulseMessage(action="ACT.QUERY.DATA", parameters={"type": "invalid"})
|
|
150
|
+
with pytest.raises(AdapterError, match="Unknown query type"):
|
|
151
|
+
adapter.to_native(msg)
|
|
152
|
+
|
|
153
|
+
def test_klines_no_symbol_raises(self, adapter):
|
|
154
|
+
msg = PulseMessage(action="ACT.QUERY.DATA", parameters={"type": "klines"})
|
|
155
|
+
with pytest.raises(AdapterError, match="Symbol required"):
|
|
156
|
+
adapter.to_native(msg)
|
|
157
|
+
|
|
158
|
+
def test_custom_category(self, adapter):
|
|
159
|
+
msg = PulseMessage(
|
|
160
|
+
action="ACT.QUERY.DATA",
|
|
161
|
+
parameters={"symbol": "BTCUSDT", "category": "linear"},
|
|
162
|
+
)
|
|
163
|
+
native = adapter.to_native(msg)
|
|
164
|
+
assert native["params"]["category"] == "linear"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# --- Test to_native: Orders ---
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class TestToNativeOrders:
|
|
171
|
+
|
|
172
|
+
def test_market_buy(self, adapter, buy_message):
|
|
173
|
+
native = adapter.to_native(buy_message)
|
|
174
|
+
assert native["method"] == "POST"
|
|
175
|
+
assert native["endpoint"] == "/v5/order/create"
|
|
176
|
+
assert native["params"]["symbol"] == "BTCUSDT"
|
|
177
|
+
assert native["params"]["side"] == "Buy"
|
|
178
|
+
assert native["params"]["orderType"] == "Market"
|
|
179
|
+
assert native["params"]["qty"] == "0.001"
|
|
180
|
+
assert native["signed"] is True
|
|
181
|
+
|
|
182
|
+
def test_sell_side(self, adapter):
|
|
183
|
+
msg = PulseMessage(
|
|
184
|
+
action="ACT.TRANSACT.REQUEST",
|
|
185
|
+
parameters={"symbol": "BTCUSDT", "side": "SELL", "quantity": 0.1},
|
|
186
|
+
validate=False,
|
|
187
|
+
)
|
|
188
|
+
native = adapter.to_native(msg)
|
|
189
|
+
assert native["params"]["side"] == "Sell"
|
|
190
|
+
|
|
191
|
+
def test_limit_order(self, adapter):
|
|
192
|
+
msg = PulseMessage(
|
|
193
|
+
action="ACT.TRANSACT.REQUEST",
|
|
194
|
+
validate=False,
|
|
195
|
+
parameters={
|
|
196
|
+
"symbol": "ETHUSDT", "side": "BUY", "quantity": 1,
|
|
197
|
+
"order_type": "LIMIT", "price": 2000,
|
|
198
|
+
},
|
|
199
|
+
)
|
|
200
|
+
native = adapter.to_native(msg)
|
|
201
|
+
assert native["params"]["orderType"] == "Limit"
|
|
202
|
+
assert native["params"]["price"] == "2000"
|
|
203
|
+
assert native["params"]["timeInForce"] == "GTC"
|
|
204
|
+
|
|
205
|
+
def test_limit_no_price_raises(self, adapter):
|
|
206
|
+
msg = PulseMessage(
|
|
207
|
+
action="ACT.TRANSACT.REQUEST",
|
|
208
|
+
parameters={"symbol": "BTCUSDT", "side": "BUY", "quantity": 1, "order_type": "LIMIT"},
|
|
209
|
+
validate=False,
|
|
210
|
+
)
|
|
211
|
+
with pytest.raises(AdapterError, match="Price required"):
|
|
212
|
+
adapter.to_native(msg)
|
|
213
|
+
|
|
214
|
+
def test_order_missing_field_raises(self, adapter):
|
|
215
|
+
msg = PulseMessage(
|
|
216
|
+
action="ACT.TRANSACT.REQUEST",
|
|
217
|
+
parameters={"symbol": "BTCUSDT", "side": "BUY"},
|
|
218
|
+
validate=False,
|
|
219
|
+
)
|
|
220
|
+
with pytest.raises(AdapterError, match="Missing required field"):
|
|
221
|
+
adapter.to_native(msg)
|
|
222
|
+
|
|
223
|
+
def test_cancel_order(self, adapter, cancel_message):
|
|
224
|
+
native = adapter.to_native(cancel_message)
|
|
225
|
+
assert native["method"] == "POST"
|
|
226
|
+
assert native["endpoint"] == "/v5/order/cancel"
|
|
227
|
+
assert native["params"]["orderId"] == "abc123"
|
|
228
|
+
assert native["signed"] is True
|
|
229
|
+
|
|
230
|
+
def test_cancel_no_symbol_raises(self, adapter):
|
|
231
|
+
msg = PulseMessage(action="ACT.CANCEL", parameters={"order_id": "123"}, validate=False)
|
|
232
|
+
with pytest.raises(AdapterError, match="Symbol required"):
|
|
233
|
+
adapter.to_native(msg)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# --- Test to_native: Account ---
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class TestToNativeAccount:
|
|
240
|
+
|
|
241
|
+
def test_order_status(self, adapter, status_message):
|
|
242
|
+
native = adapter.to_native(status_message)
|
|
243
|
+
assert native["endpoint"] == "/v5/order/realtime"
|
|
244
|
+
assert native["params"]["orderId"] == "abc123"
|
|
245
|
+
assert native["signed"] is True
|
|
246
|
+
|
|
247
|
+
def test_open_orders(self, adapter):
|
|
248
|
+
msg = PulseMessage(action="ACT.QUERY.LIST", parameters={}, validate=False)
|
|
249
|
+
native = adapter.to_native(msg)
|
|
250
|
+
assert native["endpoint"] == "/v5/order/realtime"
|
|
251
|
+
assert native["signed"] is True
|
|
252
|
+
|
|
253
|
+
def test_wallet_balance(self, adapter, balance_message):
|
|
254
|
+
native = adapter.to_native(balance_message)
|
|
255
|
+
assert native["endpoint"] == "/v5/account/wallet-balance"
|
|
256
|
+
assert native["signed"] is True
|
|
257
|
+
|
|
258
|
+
def test_unsupported_action_raises(self, adapter):
|
|
259
|
+
msg = PulseMessage(action="ACT.CREATE.TEXT", parameters={}, validate=False)
|
|
260
|
+
with pytest.raises(AdapterError, match="Unsupported action"):
|
|
261
|
+
adapter.to_native(msg)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# --- Test call_api ---
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
class TestCallAPI:
|
|
268
|
+
|
|
269
|
+
def test_get_request(self, adapter):
|
|
270
|
+
adapter._session.get.return_value = mock_response(
|
|
271
|
+
{"list": [{"symbol": "BTCUSDT", "lastPrice": "65000"}]}
|
|
272
|
+
)
|
|
273
|
+
result = adapter.call_api({
|
|
274
|
+
"method": "GET",
|
|
275
|
+
"endpoint": "/v5/market/tickers",
|
|
276
|
+
"params": {"category": "spot", "symbol": "BTCUSDT"},
|
|
277
|
+
"signed": False,
|
|
278
|
+
})
|
|
279
|
+
assert result["list"][0]["lastPrice"] == "65000"
|
|
280
|
+
|
|
281
|
+
def test_post_request(self, adapter):
|
|
282
|
+
adapter._session.post.return_value = mock_response(
|
|
283
|
+
{"orderId": "abc123", "orderLinkId": ""}
|
|
284
|
+
)
|
|
285
|
+
result = adapter.call_api({
|
|
286
|
+
"method": "POST",
|
|
287
|
+
"endpoint": "/v5/order/create",
|
|
288
|
+
"params": {"symbol": "BTCUSDT", "side": "Buy", "qty": "0.001"},
|
|
289
|
+
"signed": True,
|
|
290
|
+
})
|
|
291
|
+
assert result["orderId"] == "abc123"
|
|
292
|
+
|
|
293
|
+
def test_api_error_response(self, adapter):
|
|
294
|
+
adapter._session.get.return_value = mock_response(
|
|
295
|
+
{}, ret_code=10001, ret_msg="Invalid symbol"
|
|
296
|
+
)
|
|
297
|
+
with pytest.raises(AdapterError, match="Invalid symbol"):
|
|
298
|
+
adapter.call_api({
|
|
299
|
+
"method": "GET",
|
|
300
|
+
"endpoint": "/v5/market/tickers",
|
|
301
|
+
"params": {"symbol": "INVALID"},
|
|
302
|
+
"signed": False,
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
def test_connection_error(self, adapter):
|
|
306
|
+
adapter._session.get.side_effect = ConnectionError("Network down")
|
|
307
|
+
with pytest.raises(AdapterConnectionError, match="Cannot reach"):
|
|
308
|
+
adapter.call_api({
|
|
309
|
+
"method": "GET",
|
|
310
|
+
"endpoint": "/v5/market/tickers",
|
|
311
|
+
"signed": False,
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# --- Test Full Pipeline ---
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
class TestFullPipeline:
|
|
319
|
+
|
|
320
|
+
def test_price_query(self, adapter, price_message):
|
|
321
|
+
adapter._session.get.return_value = mock_response(
|
|
322
|
+
{"list": [{"symbol": "BTCUSDT", "lastPrice": "65000.50"}]}
|
|
323
|
+
)
|
|
324
|
+
response = adapter.send(price_message)
|
|
325
|
+
assert response.type == "RESPONSE"
|
|
326
|
+
assert response.envelope["sender"] == "adapter:bybit"
|
|
327
|
+
assert response.content["parameters"]["result"]["list"][0]["lastPrice"] == "65000.50"
|
|
328
|
+
|
|
329
|
+
def test_order_pipeline(self, adapter, buy_message):
|
|
330
|
+
adapter._session.post.return_value = mock_response(
|
|
331
|
+
{"orderId": "order-123", "orderLinkId": ""}
|
|
332
|
+
)
|
|
333
|
+
response = adapter.send(buy_message)
|
|
334
|
+
assert response.content["parameters"]["result"]["orderId"] == "order-123"
|
|
335
|
+
|
|
336
|
+
def test_pipeline_tracks_requests(self, adapter, price_message):
|
|
337
|
+
adapter._session.get.return_value = mock_response({"list": []})
|
|
338
|
+
adapter.send(price_message)
|
|
339
|
+
adapter.send(price_message)
|
|
340
|
+
assert adapter._request_count == 2
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
# --- Test Signing ---
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
class TestSigning:
|
|
347
|
+
|
|
348
|
+
def test_sign_get_headers(self, adapter):
|
|
349
|
+
params = {"category": "spot", "symbol": "BTCUSDT"}
|
|
350
|
+
headers = adapter._sign_get(params)
|
|
351
|
+
assert "X-BAPI-API-KEY" in headers
|
|
352
|
+
assert "X-BAPI-SIGN" in headers
|
|
353
|
+
assert "X-BAPI-TIMESTAMP" in headers
|
|
354
|
+
assert headers["X-BAPI-API-KEY"] == "test-key"
|
|
355
|
+
assert len(headers["X-BAPI-SIGN"]) == 64
|
|
356
|
+
|
|
357
|
+
def test_sign_post_headers(self, adapter):
|
|
358
|
+
params = {"symbol": "BTCUSDT", "side": "Buy"}
|
|
359
|
+
headers = adapter._sign_post(params)
|
|
360
|
+
assert "X-BAPI-SIGN" in headers
|
|
361
|
+
assert "Content-Type" in headers
|
|
362
|
+
|
|
363
|
+
def test_sign_without_key_raises(self, adapter):
|
|
364
|
+
adapter._api_key = None
|
|
365
|
+
with pytest.raises(AdapterError, match="API key and secret required"):
|
|
366
|
+
adapter._sign_get({"test": "param"})
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
# --- Test Supported Actions ---
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
class TestSupportedActions:
|
|
373
|
+
|
|
374
|
+
def test_supported_actions(self, adapter):
|
|
375
|
+
actions = adapter.supported_actions
|
|
376
|
+
assert "ACT.QUERY.DATA" in actions
|
|
377
|
+
assert "ACT.TRANSACT.REQUEST" in actions
|
|
378
|
+
assert "ACT.CANCEL" in actions
|
|
379
|
+
assert len(actions) == 6
|
|
380
|
+
|
|
381
|
+
def test_supports_check(self, adapter):
|
|
382
|
+
assert adapter.supports("ACT.QUERY.DATA") is True
|
|
383
|
+
assert adapter.supports("ACT.CREATE.TEXT") is False
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
# --- Test Exchange Switching ---
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
class TestExchangeSwitching:
|
|
390
|
+
"""Prove exchange switching works."""
|
|
391
|
+
|
|
392
|
+
def test_same_actions_as_binance(self):
|
|
393
|
+
from pulse_binance import BinanceAdapter
|
|
394
|
+
binance = BinanceAdapter(api_key="k", api_secret="s")
|
|
395
|
+
bybit = BybitAdapter(api_key="k", api_secret="s")
|
|
396
|
+
assert set(binance.supported_actions) == set(bybit.supported_actions)
|
|
397
|
+
|
|
398
|
+
def test_same_message_works(self, adapter, price_message):
|
|
399
|
+
adapter._session.get.return_value = mock_response({"list": []})
|
|
400
|
+
response = adapter.send(price_message)
|
|
401
|
+
assert response.type == "RESPONSE"
|